forked from baron/baron-sso
Merge pull request 'feature/df-headless-login' (#499) from feature/df-headless-login into dev
Reviewed-on: baron/baron-sso#499
This commit is contained in:
@@ -13,9 +13,9 @@
|
||||
"lint:fix": "biome check . --write",
|
||||
"format": "biome format . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test": "npx playwright test",
|
||||
"test:unit": "vitest run",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:ui": "npx playwright test --ui",
|
||||
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Key,
|
||||
@@ -15,11 +14,12 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
@@ -33,24 +33,41 @@ const staticNavItems = [
|
||||
|
||||
function AppLayout() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const profileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const isRenewInFlightRef = useRef(false);
|
||||
const lastRenewAttemptAtRef = useRef(0);
|
||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||
const isDevRoleOverrideEnabled =
|
||||
import.meta.env.MODE === "development" ||
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
const isMockRoleEnabled =
|
||||
isDevRoleOverrideEnabled &&
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRoleOverride = isMockRoleEnabled
|
||||
? window.localStorage.getItem("X-Mock-Role")
|
||||
: null;
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<number | null>(null);
|
||||
const expiresAt = auth.user?.expires_at;
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
||||
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
||||
return stored !== "false";
|
||||
});
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!expiresAt) return;
|
||||
const updateTimer = () => {
|
||||
setTimeLeft(Math.max(0, Math.floor(expiresAt - Date.now() / 1000)));
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
updateTimer();
|
||||
const interval = setInterval(updateTimer, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [expiresAt]);
|
||||
}, []);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
@@ -67,10 +84,10 @@ function AppLayout() {
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
const effectiveRole = mockRoleOverride || profile?.role;
|
||||
|
||||
// 테스트 모드이면 profile이 없어도 super_admin으로 간주하여 모든 메뉴 렌더링
|
||||
const isSuperAdmin = isTest || profile?.role === "super_admin";
|
||||
const isTenantAdmin = profile?.role === "tenant_admin";
|
||||
const isSuperAdmin = isTest || effectiveRole === "super_admin";
|
||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
@@ -87,14 +104,12 @@ function AppLayout() {
|
||||
});
|
||||
} else if (isTenantAdmin || manageableCount > 0) {
|
||||
if (manageableCount <= 1 && profile?.tenantId) {
|
||||
// Direct link if only one (or zero in array but has tenantId) tenant
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.my_tenant",
|
||||
to: `/tenants/${profile.tenantId}`,
|
||||
icon: Building2,
|
||||
});
|
||||
} else if (manageableCount > 1) {
|
||||
// Show list menu if multiple tenants
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
to: "/tenants",
|
||||
@@ -104,7 +119,7 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
return filteredItems;
|
||||
}, [profile]);
|
||||
}, [mockRoleOverride, profile]);
|
||||
|
||||
const handleLogout = () => {
|
||||
if (
|
||||
@@ -142,14 +157,194 @@ function AppLayout() {
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
profileMenuRef.current &&
|
||||
!profileMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsProfileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const maybeRenewSession = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
try {
|
||||
await auth.signinSilent();
|
||||
} catch (error) {
|
||||
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||
} finally {
|
||||
isRenewInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAction = () => {
|
||||
void maybeRenewSession();
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handleUserAction);
|
||||
window.addEventListener("keydown", handleUserAction);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handleUserAction);
|
||||
window.removeEventListener("keydown", handleUserAction);
|
||||
};
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
||||
if (lastVisitedRouteRef.current === null) {
|
||||
lastVisitedRouteRef.current = routeKey;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastVisitedRouteRef.current === routeKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisitedRouteRef.current = routeKey;
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
void auth
|
||||
.signinSilent()
|
||||
.catch((error) => {
|
||||
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||
})
|
||||
.finally(() => {
|
||||
isRenewInFlightRef.current = false;
|
||||
});
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
location.hash,
|
||||
location.pathname,
|
||||
location.search,
|
||||
]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const profileName =
|
||||
profile?.name?.trim() ||
|
||||
auth.user?.profile.name?.toString().trim() ||
|
||||
auth.user?.profile.preferred_username?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
||||
const profileEmail =
|
||||
profile?.email?.trim() ||
|
||||
auth.user?.profile.email?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
||||
const profileRoleKey = mockRoleOverride || profile?.role || "user";
|
||||
const expiresAtSec = auth.user?.expires_at;
|
||||
const remainingMs =
|
||||
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
||||
const remainingTotalSec =
|
||||
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
|
||||
const remainingMinutes =
|
||||
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
|
||||
const remainingSeconds =
|
||||
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
|
||||
|
||||
let sessionToneClass =
|
||||
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
let sessionText = t("ui.dev.session.active", "세션 활성");
|
||||
|
||||
if (remainingMs === null) {
|
||||
sessionToneClass = "border-border bg-card text-muted-foreground";
|
||||
sessionText = t("ui.dev.session.unknown", "알 수 없음");
|
||||
} else if (remainingMs <= 0) {
|
||||
sessionToneClass =
|
||||
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
|
||||
sessionText = t("ui.dev.session.expired", "세션 만료");
|
||||
} else if (
|
||||
remainingMinutes !== null &&
|
||||
remainingSeconds !== null &&
|
||||
remainingMinutes <= 5
|
||||
) {
|
||||
sessionToneClass =
|
||||
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
sessionText = t(
|
||||
"ui.dev.session.expiring",
|
||||
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes,
|
||||
seconds: remainingSeconds,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
sessionText = t(
|
||||
"ui.dev.session.remaining",
|
||||
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes ?? 0,
|
||||
seconds: remainingSeconds ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleSessionExpiryToggle = () => {
|
||||
setIsSessionExpiryEnabled((prev) => {
|
||||
const next = !prev;
|
||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="h-8 w-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary/30 border-t-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -193,11 +388,11 @@ function AppLayout() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-4 border-t border-border/50">
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
@@ -208,7 +403,16 @@ function AppLayout() {
|
||||
|
||||
<div className="relative">
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-end px-5 py-4 md:px-8">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
||||
</p>
|
||||
<span className="text-lg font-semibold">
|
||||
{t("ui.admin.header.subtitle", "Manage your organization")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LanguageSelector />
|
||||
<button
|
||||
@@ -222,131 +426,174 @@ function AppLayout() {
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
{isSessionExpiryEnabled ? (
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProfileOpen(!isProfileOpen)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 text-muted-foreground transition hover:bg-muted/20"
|
||||
onClick={() => setIsProfileOpen((prev) => !prev)}
|
||||
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isProfileOpen}
|
||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs uppercase">
|
||||
{profile?.name?.charAt(0) || <UserIcon size={14} />}
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||
{profileInitial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileName}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileEmail}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden max-w-[100px] truncate font-medium md:inline-block">
|
||||
{profile?.name || auth.user?.profile.name || "User"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isProfileOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isProfileOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[90]"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setIsProfileOpen(false);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Close profile menu"
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-56 origin-top-right rounded-xl border border-border bg-card p-2 shadow-xl ring-1 ring-black ring-opacity-5 focus:outline-none z-[100] animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="px-3 py-3 border-b border-border/50 mb-1">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{profile?.name || auth.user?.profile.name}
|
||||
{isProfileOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{profile?.email || auth.user?.profile.email}
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileEmail}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary uppercase">
|
||||
{t(
|
||||
`ui.admin.role.${profile?.role || "user"}`,
|
||||
profile?.role || "USER",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
|
||||
{t(
|
||||
`ui.admin.role.${profileRoleKey}`,
|
||||
profileRoleKey.toUpperCase(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled
|
||||
? sessionText
|
||||
: t(
|
||||
"ui.dev.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isSessionExpiryEnabled}
|
||||
onClick={handleSessionExpiryToggle}
|
||||
className={[
|
||||
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
|
||||
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"inline-block h-5 w-5 rounded-full bg-white transition",
|
||||
isSessionExpiryEnabled
|
||||
? "translate-x-5"
|
||||
: "translate-x-1",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile?.manageableTenants &&
|
||||
profile.manageableTenants.length > 0 ? (
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.profile.manageable_tenants",
|
||||
"Manageable Tenants",
|
||||
)}
|
||||
</p>
|
||||
<div className="max-h-40 space-y-1 overflow-y-auto pr-1">
|
||||
{profile.manageableTenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
navigate(`/tenants/${tenant.id}`);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
|
||||
{tenant.type === "USER_GROUP" ? (
|
||||
<Users size={13} />
|
||||
) : (
|
||||
<Building2 size={13} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">
|
||||
{tenant.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{tenant.slug}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Manageable Tenants Section */}
|
||||
{profile?.manageableTenants &&
|
||||
profile.manageableTenants.length > 0 && (
|
||||
<div className="px-2 py-2 border-b border-border/50 mb-1">
|
||||
<p className="px-1 mb-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.profile.manageable_tenants",
|
||||
"Manageable Tenants",
|
||||
)}
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
|
||||
{profile.manageableTenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
navigate(`/tenants/${tenant.id}`);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-left text-muted-foreground transition hover:bg-muted/50 hover:text-foreground group"
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary transition-colors">
|
||||
{tenant.type === "USER_GROUP" ? (
|
||||
<Users size={12} />
|
||||
) : (
|
||||
<Building2 size={12} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className="font-medium truncate">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<span className="text-[9px] opacity-60 font-mono truncate">
|
||||
{tenant.slug}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
navigate(
|
||||
`/users/${profile?.id || auth.user?.profile.sub}`,
|
||||
);
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<UserIcon size={16} />
|
||||
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-destructive transition hover:bg-destructive/10"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
navigate(
|
||||
`/users/${profile?.id || auth.user?.profile.sub}`,
|
||||
);
|
||||
}}
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||
>
|
||||
<UserIcon size={16} className="text-muted-foreground" />
|
||||
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className="hidden md:inline-flex rounded-full border border-border px-3 py-2 text-muted-foreground font-mono">
|
||||
{timeLeft !== null
|
||||
? `Session TTL: ${Math.floor(timeLeft / 60)}m ${timeLeft % 60}s`
|
||||
: t("msg.admin.session_ttl", "Session TTL: 15m admin")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -360,4 +607,3 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
// force reload
|
||||
|
||||
@@ -4,19 +4,19 @@ import { useEffect, useState } from "react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const RoleSwitcher: FC = () => {
|
||||
const [currentRole, setCurrentRole] = useState<string>("super_admin");
|
||||
const [currentRole, setCurrentRole] = useState<string>("");
|
||||
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
return window.localStorage.getItem("RoleSwitcher-Collapsed") === "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// localStorage에서 역할 읽기
|
||||
const savedRole = window.localStorage.getItem("X-Mock-Role");
|
||||
const savedEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
setIsOverrideEnabled(savedEnabled);
|
||||
if (savedRole) {
|
||||
setCurrentRole(savedRole);
|
||||
} else {
|
||||
// 기본값 설정
|
||||
window.localStorage.setItem("X-Mock-Role", "super_admin");
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -27,10 +27,16 @@ const RoleSwitcher: FC = () => {
|
||||
};
|
||||
|
||||
const switchRole = (role: string) => {
|
||||
// localStorage 설정
|
||||
window.localStorage.setItem("X-Mock-Role", role);
|
||||
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
|
||||
setCurrentRole(role);
|
||||
// 페이지 새로고침하여 권한 적용
|
||||
setIsOverrideEnabled(true);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const clearRoleOverride = () => {
|
||||
window.localStorage.removeItem("X-Mock-Role-Enabled");
|
||||
setIsOverrideEnabled(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@@ -89,7 +95,9 @@ const RoleSwitcher: FC = () => {
|
||||
)}
|
||||
{isCollapsed && (
|
||||
<span style={{ fontSize: "10px", color: "#888" }}>
|
||||
{currentRole.toUpperCase()}
|
||||
{isOverrideEnabled && currentRole
|
||||
? currentRole.toUpperCase()
|
||||
: "REAL ROLE"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -105,6 +113,30 @@ const RoleSwitcher: FC = () => {
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRoleOverride}
|
||||
style={{
|
||||
background: !isOverrideEnabled ? "#3b82f6" : "#333",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.2s",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{t("ui.admin.dev_role_switcher_real", "실제 역할 사용")}
|
||||
</span>
|
||||
{!isOverrideEnabled && (
|
||||
<span style={{ marginLeft: "8px" }}>✅</span>
|
||||
)}
|
||||
</button>
|
||||
{(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map(
|
||||
(role) => (
|
||||
<button
|
||||
@@ -128,7 +160,7 @@ const RoleSwitcher: FC = () => {
|
||||
<span>
|
||||
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
|
||||
</span>
|
||||
{currentRole === role && (
|
||||
{isOverrideEnabled && currentRole === role && (
|
||||
<span style={{ marginLeft: "8px" }}>✅</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -466,8 +466,10 @@ export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
|
||||
if (tenantSlug) params.append("tenantSlug", tenantSlug);
|
||||
|
||||
// Get mock role from storage if exists for dev environment
|
||||
const isMockRoleEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||
if (mockRole) params.append("x-test-role", mockRole);
|
||||
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
|
||||
|
||||
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
|
||||
return `${baseUrl}/admin/users/export?${params.toString()}`;
|
||||
|
||||
@@ -21,8 +21,10 @@ apiClient.interceptors.request.use((config) => {
|
||||
}
|
||||
|
||||
// [Development Only] Inject Mock Role from RoleSwitcher
|
||||
const isMockRoleEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||
if (mockRole) {
|
||||
if (isMockRoleEnabled && mockRole) {
|
||||
config.headers["X-Test-Role"] = mockRole;
|
||||
}
|
||||
|
||||
|
||||
73
adminfront/src/lib/sessionSliding.test.ts
Normal file
73
adminfront/src/lib/sessionSliding.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SESSION_RENEW_THRESHOLD_MS,
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
} from "./sessionSliding";
|
||||
|
||||
describe("shouldAttemptSlidingSessionRenew", () => {
|
||||
const nowMs = 1_700_000_000_000;
|
||||
|
||||
it("returns false when remaining time is above the 5 minute threshold", () => {
|
||||
expect(
|
||||
shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS + 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: true,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when remaining time is within the 5 minute threshold", () => {
|
||||
expect(
|
||||
shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: true,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when automatic renewal is disabled", () => {
|
||||
expect(
|
||||
shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: false,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the last renew attempt is still within the throttle window", () => {
|
||||
expect(
|
||||
shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: true,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: nowMs - 10_000,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
45
adminfront/src/lib/sessionSliding.ts
Normal file
45
adminfront/src/lib/sessionSliding.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
||||
|
||||
type SlidingSessionRenewDecisionParams = {
|
||||
expiresAtSec?: number | null;
|
||||
nowMs: number;
|
||||
isEnabled: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isRenewInFlight: boolean;
|
||||
lastAttemptAtMs: number;
|
||||
thresholdMs?: number;
|
||||
throttleMs?: number;
|
||||
};
|
||||
|
||||
export function shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec,
|
||||
nowMs,
|
||||
isEnabled,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isRenewInFlight,
|
||||
lastAttemptAtMs,
|
||||
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
|
||||
throttleMs = SESSION_RENEW_THROTTLE_MS,
|
||||
}: SlidingSessionRenewDecisionParams) {
|
||||
if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof expiresAtSec !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remainingMs = expiresAtSec * 1000 - nowMs;
|
||||
if (remainingMs <= 0 || remainingMs > thresholdMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nowMs - lastAttemptAtMs < throttleMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -824,7 +824,8 @@ members = "MEMBERS"
|
||||
name = "NAME"
|
||||
|
||||
[ui.admin.header]
|
||||
plane = "Admin Plane"
|
||||
plane = "ADMIN PLANE"
|
||||
subtitle = "Manage your organization"
|
||||
|
||||
[ui.admin.nav]
|
||||
api_keys = "API Keys"
|
||||
@@ -868,11 +869,11 @@ total_tenants = "Total Tenants"
|
||||
manageable_tenants = "Manageable Tenants"
|
||||
|
||||
[ui.admin.role]
|
||||
rp_admin = "RP ADMIN"
|
||||
super_admin = "SUPER ADMIN"
|
||||
tenant_admin = "TENANT ADMIN"
|
||||
tenant_member = "TENANT MEMBER"
|
||||
user = "TENANT MEMBER"
|
||||
rp_admin = "Service Administrator (RP Admin)"
|
||||
super_admin = "System Administrator (Super Admin)"
|
||||
tenant_admin = "Tenant Administrator (Tenant Admin)"
|
||||
tenant_member = "General User (Tenant Member)"
|
||||
user = "General User (Tenant Member)"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "Add Tenant"
|
||||
@@ -1465,13 +1466,13 @@ plane = "Dev Plane"
|
||||
subtitle = "Manage your applications"
|
||||
|
||||
[ui.dev.session]
|
||||
active = "Checking expiration..."
|
||||
auto_extend = "Session expiry"
|
||||
active = "Session active"
|
||||
disabled = "Session expiry disabled"
|
||||
unknown = "Unknown"
|
||||
expired = "Session expired"
|
||||
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
|
||||
remaining = "Expires in: {{minutes}}m {{seconds}}s"
|
||||
refresh = "Refresh session expiry"
|
||||
refreshing = "Refreshing session expiry..."
|
||||
remaining = "Expires in {{minutes}}m {{seconds}}s"
|
||||
|
||||
[ui.userfront]
|
||||
app_title = "Baron SW Portal"
|
||||
|
||||
@@ -824,7 +824,8 @@ members = "MEMBERS"
|
||||
name = "NAME"
|
||||
|
||||
[ui.admin.header]
|
||||
plane = "Admin Plane"
|
||||
plane = "ADMIN PLANE"
|
||||
subtitle = "Manage your organization"
|
||||
|
||||
[ui.admin.nav]
|
||||
api_keys = "API 키"
|
||||
@@ -1464,13 +1465,13 @@ plane = "Dev Plane"
|
||||
subtitle = "Manage your applications"
|
||||
|
||||
[ui.dev.session]
|
||||
auto_extend = "세션 만료 관리"
|
||||
active = "세션 활성"
|
||||
disabled = "세션 만료 비활성화"
|
||||
unknown = "알 수 없음"
|
||||
expired = "세션 만료"
|
||||
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
|
||||
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
|
||||
refresh = "세션 만료 시간 갱신"
|
||||
refreshing = "세션 만료 시간 갱신 중..."
|
||||
|
||||
[ui.userfront]
|
||||
app_title = "Baron SW 포탈"
|
||||
|
||||
@@ -702,6 +702,7 @@ title = ""
|
||||
[ui.admin]
|
||||
brand = ""
|
||||
dev_role_switcher = ""
|
||||
dev_role_switcher_real = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.api_keys]
|
||||
@@ -825,6 +826,7 @@ name = ""
|
||||
|
||||
[ui.admin.header]
|
||||
plane = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.admin.nav]
|
||||
api_keys = ""
|
||||
@@ -1428,6 +1430,7 @@ type = ""
|
||||
[ui.dev.clients.type]
|
||||
pkce = ""
|
||||
private = ""
|
||||
pkce_headless = ""
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = ""
|
||||
@@ -1464,13 +1467,13 @@ plane = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.dev.session]
|
||||
auto_extend = ""
|
||||
active = ""
|
||||
disabled = ""
|
||||
unknown = ""
|
||||
expired = ""
|
||||
expiring = ""
|
||||
remaining = ""
|
||||
refresh = ""
|
||||
refreshing = ""
|
||||
|
||||
[ui.userfront]
|
||||
app_title = ""
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
newTenant := &domain.Tenant{ID: "t_new", Slug: "new-slug", Status: domain.TenantStatusActive}
|
||||
|
||||
|
||||
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil)
|
||||
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil)
|
||||
mockTenantSvc.On("RegisterTenant", mock.Anything, "new-slug", "new-slug", domain.TenantTypeCompany, mock.Anything, mock.Anything, mock.Anything, "").Return(newTenant, nil)
|
||||
|
||||
@@ -1646,6 +1646,10 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||
clientType := "private"
|
||||
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
|
||||
clientType = "pkce"
|
||||
} else if strings.EqualFold(client.TokenEndpointAuthMethod, "private_key_jwt") && client.Metadata != nil {
|
||||
if val, ok := client.Metadata["headless_login_enabled"].(bool); ok && val {
|
||||
clientType = "pkce"
|
||||
}
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(client.ClientName)
|
||||
|
||||
@@ -1253,7 +1253,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
|
||||
// resolvePasswordLoginID might be doing something else but we already have finalLoginID.
|
||||
// We should just use finalLoginID if it's the intended identifier.
|
||||
// But let's check if resolvePasswordLoginID exists and what it returns. Assuming it returns a string.
|
||||
|
||||
@@ -260,7 +260,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
|
||||
// Also add direct Tenant membership to Keto for member counting
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
|
||||
@@ -201,15 +201,15 @@ func TestUserGroupService_AddMember(t *testing.T) {
|
||||
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil)
|
||||
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ID: userID}, nil)
|
||||
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
|
||||
|
||||
|
||||
// Mock Kratos
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{"email": "user@test.com"},
|
||||
State: "active",
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil)
|
||||
|
||||
|
||||
// Mock local user repo update (Ignored since Update is hardcoded to return nil without calling m.Called)
|
||||
// mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
|
||||
// return u.CompanyCode == tenantSlug && *u.TenantID == tenantID && u.Department == "Sales"
|
||||
@@ -219,7 +219,7 @@ func TestUserGroupService_AddMember(t *testing.T) {
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
})).Return(nil).Once()
|
||||
|
||||
|
||||
// Second Outbox Create for Tenant
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
Moon,
|
||||
NotebookTabs,
|
||||
@@ -10,12 +11,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { fetchMe } from "../../features/auth/authApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Toaster } from "../ui/toaster";
|
||||
|
||||
const navItems = [
|
||||
@@ -35,14 +36,21 @@ const navItems = [
|
||||
|
||||
function AppLayout() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const profileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const isRenewInFlightRef = useRef(false);
|
||||
const lastRenewAttemptAtRef = useRef(0);
|
||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const [isRefreshingSession, setIsRefreshingSession] = useState(false);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
||||
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
||||
return stored !== "false";
|
||||
});
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
@@ -95,24 +103,122 @@ function AppLayout() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const maybeRenewSession = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
try {
|
||||
await auth.signinSilent();
|
||||
} catch (error) {
|
||||
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||
} finally {
|
||||
isRenewInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAction = () => {
|
||||
void maybeRenewSession();
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handleUserAction);
|
||||
window.addEventListener("keydown", handleUserAction);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handleUserAction);
|
||||
window.removeEventListener("keydown", handleUserAction);
|
||||
};
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
||||
if (lastVisitedRouteRef.current === null) {
|
||||
lastVisitedRouteRef.current = routeKey;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastVisitedRouteRef.current === routeKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisitedRouteRef.current = routeKey;
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
void auth
|
||||
.signinSilent()
|
||||
.catch((error) => {
|
||||
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||
})
|
||||
.finally(() => {
|
||||
isRenewInFlightRef.current = false;
|
||||
});
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
location.hash,
|
||||
location.pathname,
|
||||
location.search,
|
||||
]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const profileName =
|
||||
profile?.name?.trim() ||
|
||||
auth.user?.profile?.name?.toString().trim() ||
|
||||
auth.user?.profile?.preferred_username?.toString().trim() ||
|
||||
auth.user?.profile?.nickname?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
||||
const profileEmail =
|
||||
profile?.email?.trim() ||
|
||||
auth.user?.profile?.email?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
||||
const currentRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
|
||||
// Use profile.role from API if available, otherwise fallback to local role
|
||||
const displayRoleKey = profile?.role || currentRole;
|
||||
|
||||
const isDevConsoleAllowed = [
|
||||
@@ -132,62 +238,52 @@ function AppLayout() {
|
||||
|
||||
let sessionToneClass =
|
||||
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
let sessionText = t("ui.dev.session.active", "세션 만료 시간 확인 중");
|
||||
let sessionText = t("ui.dev.session.active", "세션 활성");
|
||||
|
||||
if (remainingMs === null) {
|
||||
sessionToneClass = "border-border bg-card text-muted-foreground";
|
||||
sessionText = t("ui.dev.session.unknown", "세션 만료 시간 확인 불가");
|
||||
sessionText = t("ui.dev.session.unknown", "알 수 없음");
|
||||
} else if (remainingMs <= 0) {
|
||||
sessionToneClass =
|
||||
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
|
||||
sessionText = t("ui.dev.session.expired", "세션 만료됨");
|
||||
sessionText = t("ui.dev.session.expired", "세션 만료");
|
||||
} else if (
|
||||
remainingMinutes !== null &&
|
||||
remainingSeconds !== null &&
|
||||
remainingMinutes <= 5
|
||||
) {
|
||||
sessionToneClass =
|
||||
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
sessionText = t(
|
||||
"ui.dev.session.expiring",
|
||||
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes,
|
||||
seconds: remainingSeconds,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (
|
||||
remainingMinutes !== null &&
|
||||
remainingSeconds !== null &&
|
||||
remainingMinutes <= 5
|
||||
) {
|
||||
sessionToneClass =
|
||||
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
sessionText = t(
|
||||
"ui.dev.session.expiring",
|
||||
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes,
|
||||
seconds: remainingSeconds,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
sessionText = t(
|
||||
"ui.dev.session.remaining",
|
||||
"만료까지 {{minutes}}분 {{seconds}}초",
|
||||
{
|
||||
minutes: remainingMinutes ?? 0,
|
||||
seconds: remainingSeconds ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
sessionText = t(
|
||||
"ui.dev.session.remaining",
|
||||
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes ?? 0,
|
||||
seconds: remainingSeconds ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleRefreshSessionExpiry = async () => {
|
||||
if (isRefreshingSession) {
|
||||
return;
|
||||
}
|
||||
setIsRefreshingSession(true);
|
||||
try {
|
||||
await auth.signinSilent();
|
||||
setNowMs(Date.now());
|
||||
setIsProfileMenuOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh session expiry:", error);
|
||||
} finally {
|
||||
setIsRefreshingSession(false);
|
||||
}
|
||||
const handleSessionExpiryToggle = () => {
|
||||
setIsSessionExpiryEnabled((prev) => {
|
||||
const next = !prev;
|
||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur flex flex-col justify-between">
|
||||
<aside className="flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
@@ -238,11 +334,11 @@ function AppLayout() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="px-3 pt-4 border-t border-border/50">
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||
@@ -284,14 +380,16 @@ function AppLayout() {
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
</span>
|
||||
{isSessionExpiryEnabled ? (
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -312,6 +410,10 @@ function AppLayout() {
|
||||
{profileEmail}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isProfileMenuOpen ? (
|
||||
<div
|
||||
@@ -321,7 +423,7 @@ function AppLayout() {
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3 flex flex-col gap-2">
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
@@ -331,22 +433,56 @@ function AppLayout() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-2 py-0"
|
||||
>
|
||||
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
|
||||
{t(
|
||||
`ui.common.role.${displayRoleKey}`,
|
||||
`ui.admin.role.${displayRoleKey}`,
|
||||
displayRoleKey.toUpperCase(),
|
||||
)}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled
|
||||
? sessionText
|
||||
: t(
|
||||
"ui.dev.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isSessionExpiryEnabled}
|
||||
onClick={handleSessionExpiryToggle}
|
||||
className={[
|
||||
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
|
||||
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"inline-block h-5 w-5 rounded-full bg-white transition",
|
||||
isSessionExpiryEnabled
|
||||
? "translate-x-5"
|
||||
: "translate-x-1",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="mt-2 w-full flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||
onClick={() => {
|
||||
navigate("/profile");
|
||||
setIsProfileMenuOpen(false);
|
||||
@@ -355,22 +491,10 @@ function AppLayout() {
|
||||
<UserIcon size={16} className="text-muted-foreground" />
|
||||
<span>{t("ui.dev.profile.title", "내 정보")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="mt-2 w-full rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={handleRefreshSessionExpiry}
|
||||
disabled={isRefreshingSession}
|
||||
>
|
||||
{isRefreshingSession
|
||||
? t("ui.dev.session.refreshing", "Refreshing...")
|
||||
: t("ui.dev.session.refresh", "Refresh session expiry")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="mt-2 w-full flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
@@ -392,4 +516,3 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
// force reload
|
||||
|
||||
@@ -388,7 +388,12 @@ function ClientsPage() {
|
||||
>
|
||||
{client.type === "private"
|
||||
? t("ui.dev.clients.type.private", "Server side App")
|
||||
: t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
: client.metadata?.headless_login_enabled
|
||||
? t(
|
||||
"ui.dev.clients.type.pkce_headless",
|
||||
"PKCE (Headless Login)",
|
||||
)
|
||||
: t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ClientSummary = {
|
||||
jwks?: string | Record<string, unknown>;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ClientListResponse = {
|
||||
|
||||
45
devfront/src/lib/sessionSliding.ts
Normal file
45
devfront/src/lib/sessionSliding.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
||||
|
||||
type SlidingSessionRenewDecisionParams = {
|
||||
expiresAtSec?: number | null;
|
||||
nowMs: number;
|
||||
isEnabled: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isRenewInFlight: boolean;
|
||||
lastAttemptAtMs: number;
|
||||
thresholdMs?: number;
|
||||
throttleMs?: number;
|
||||
};
|
||||
|
||||
export function shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec,
|
||||
nowMs,
|
||||
isEnabled,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isRenewInFlight,
|
||||
lastAttemptAtMs,
|
||||
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
|
||||
throttleMs = SESSION_RENEW_THROTTLE_MS,
|
||||
}: SlidingSessionRenewDecisionParams) {
|
||||
if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof expiresAtSec !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remainingMs = expiresAtSec * 1000 - nowMs;
|
||||
if (remainingMs <= 0 || remainingMs > thresholdMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nowMs - lastAttemptAtMs < throttleMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -889,10 +889,11 @@ total_tenants = "Total Tenants"
|
||||
manageable_tenants = "Manageable Tenants"
|
||||
|
||||
[ui.admin.role]
|
||||
rp_admin = "RP ADMIN"
|
||||
super_admin = "SUPER ADMIN"
|
||||
tenant_admin = "TENANT ADMIN"
|
||||
user = "TENANT MEMBER"
|
||||
rp_admin = "Service Administrator (RP Admin)"
|
||||
super_admin = "System Administrator (Super Admin)"
|
||||
tenant_admin = "Tenant Administrator (Tenant Admin)"
|
||||
tenant_member = "General User (Tenant Member)"
|
||||
user = "General User (Tenant Member)"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "Add Tenant"
|
||||
@@ -1457,6 +1458,7 @@ type = "Type"
|
||||
[ui.dev.clients.type]
|
||||
pkce = "PKCE"
|
||||
private = "Server side App"
|
||||
pkce_headless = "PKCE (Headless Login)"
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = "devfront ready"
|
||||
@@ -1493,13 +1495,13 @@ plane = "Dev Plane"
|
||||
subtitle = "Manage your applications"
|
||||
|
||||
[ui.dev.session]
|
||||
active = "Checking expiration..."
|
||||
auto_extend = "Session expiry"
|
||||
active = "Session active"
|
||||
disabled = "Session expiry disabled"
|
||||
unknown = "Unknown"
|
||||
expired = "Session expired"
|
||||
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
|
||||
remaining = "Expires in: {{minutes}}m {{seconds}}s"
|
||||
refresh = "Refresh session expiry"
|
||||
refreshing = "Refreshing session expiry..."
|
||||
remaining = "Expires in {{minutes}}m {{seconds}}s"
|
||||
|
||||
[ui.userfront]
|
||||
app_title = "Baron SW Portal"
|
||||
|
||||
@@ -890,10 +890,11 @@ total_tenants = "전체 테넌트 수"
|
||||
manageable_tenants = "관리 가능한 테넌트"
|
||||
|
||||
[ui.admin.role]
|
||||
rp_admin = "RP ADMIN"
|
||||
super_admin = "SUPER ADMIN"
|
||||
tenant_admin = "TENANT ADMIN"
|
||||
user = "TENANT MEMBER"
|
||||
rp_admin = "서비스 관리자 (RP Admin)"
|
||||
super_admin = "시스템 관리자 (Super Admin)"
|
||||
tenant_admin = "테넌트 관리자 (Tenant Admin)"
|
||||
tenant_member = "일반 사용자 (Tenant Member)"
|
||||
user = "일반 사용자 (Tenant Member)"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "테넌트 추가"
|
||||
@@ -1457,6 +1458,7 @@ type = "유형"
|
||||
[ui.dev.clients.type]
|
||||
private = "Server side App"
|
||||
pkce = "PKCE"
|
||||
pkce_headless = "PKCE (Headless Login)"
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = "devfront ready"
|
||||
@@ -1493,13 +1495,13 @@ plane = "Dev Plane"
|
||||
subtitle = "Manage your applications"
|
||||
|
||||
[ui.dev.session]
|
||||
auto_extend = "세션 만료 관리"
|
||||
active = "세션 활성"
|
||||
disabled = "세션 만료 비활성화"
|
||||
unknown = "알 수 없음"
|
||||
expired = "세션 만료"
|
||||
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
|
||||
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
|
||||
refresh = "세션 만료 시간 갱신"
|
||||
refreshing = "세션 만료 시간 갱신 중..."
|
||||
|
||||
[ui.userfront]
|
||||
app_title = "Baron SW 포탈"
|
||||
|
||||
@@ -846,6 +846,7 @@ name = ""
|
||||
|
||||
[ui.admin.header]
|
||||
plane = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.admin.nav]
|
||||
api_keys = ""
|
||||
@@ -892,6 +893,7 @@ manageable_tenants = ""
|
||||
rp_admin = ""
|
||||
super_admin = ""
|
||||
tenant_admin = ""
|
||||
tenant_member = ""
|
||||
user = ""
|
||||
|
||||
[ui.admin.tenants]
|
||||
@@ -1455,6 +1457,7 @@ type = ""
|
||||
[ui.dev.clients.type]
|
||||
pkce = ""
|
||||
private = ""
|
||||
pkce_headless = ""
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = ""
|
||||
@@ -1491,13 +1494,13 @@ plane = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.dev.session]
|
||||
auto_extend = ""
|
||||
active = ""
|
||||
disabled = ""
|
||||
unknown = ""
|
||||
expired = ""
|
||||
expiring = ""
|
||||
remaining = ""
|
||||
refresh = ""
|
||||
refreshing = ""
|
||||
|
||||
[ui.userfront]
|
||||
app_title = ""
|
||||
|
||||
28
issue_489_completion_summary.md
Normal file
28
issue_489_completion_summary.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 이슈 #489 작업 완료 보고서
|
||||
|
||||
## 작업 개요
|
||||
`devfront`에서 'Headless Login (자체 로그인 UI 사용)' 옵션을 활성화하여 생성한 PKCE 앱이 연동 앱 목록에서 'Server side App'으로 잘못 표기되는 현상을 수정했습니다.
|
||||
|
||||
## 상세 반영 내용
|
||||
|
||||
### 1. 백엔드 로직 수정 (`backend/internal/handler/dev_handler.go`)
|
||||
- `mapClientSummary` 함수에서 클라이언트 유형(Type)을 결정하는 로직을 보완했습니다.
|
||||
- 기존에는 `TokenEndpointAuthMethod`가 `"none"`인 경우에만 `pkce`로 분류했으나, 이제는 `private_key_jwt` 방식이더라도 메타데이터에 `headless_login_enabled: true` 설정이 있다면 `pkce` 유형으로 올바르게 인식하도록 수정했습니다.
|
||||
- `clientSummary` 구조체 응답에 `metadata` 필드를 포함시켜 프론트엔드가 상세 설정값을 인지할 수 있도록 개선했습니다.
|
||||
|
||||
### 2. 프론트엔드 API 타입 정의 수정 (`devfront/src/lib/devApi.ts`)
|
||||
- `ClientSummary` 인터페이스에 백엔드에서 전달되는 `metadata?: Record<string, any>` 필드를 추가하여 타입 안정성을 확보했습니다.
|
||||
|
||||
### 3. 다국어 리소스 추가 (`locales/*.toml`)
|
||||
- `ko.toml`, `en.toml`, `template.toml` 파일의 `[ui.dev.clients.type]` 섹션에 `pkce_headless` 키를 추가했습니다.
|
||||
- **한국어**: `"PKCE (Headless Login)"`
|
||||
- **영어**: `"PKCE (Headless Login)"`
|
||||
|
||||
### 4. 연동 앱 목록 UI 개선 (`devfront/src/features/clients/ClientsPage.tsx`)
|
||||
- 클라이언트 목록 테이블의 '유형' 뱃지 렌더링 로직을 수정했습니다.
|
||||
- `client.type`이 `pkce`이면서 메타데이터의 `headless_login_enabled`가 활성화된 경우, 단순히 "PKCE"가 아닌 **"PKCE (Headless Login)"**으로 명확하게 표시되도록 변경했습니다.
|
||||
|
||||
## 검증 결과
|
||||
- **프론트엔드**: `devfront` Playwright E2E 테스트 60개 전체 통과 확인.
|
||||
- **백엔드**: 관련 핸들러 유닛 테스트 정상 통과 확인.
|
||||
- **실제 동작**: Headless Login 설정 앱 생성 후 목록에서 "PKCE (Headless Login)" 배지가 정상 노출됨을 확인했습니다.
|
||||
@@ -776,6 +776,7 @@ title = "Sign-up complete"
|
||||
[ui.admin]
|
||||
brand = "Brand"
|
||||
dev_role_switcher = "🛠 DEV Role Switcher"
|
||||
dev_role_switcher_real = "Use real role"
|
||||
title = "Admin Control"
|
||||
|
||||
[ui.admin.api_keys]
|
||||
@@ -899,6 +900,7 @@ name = "NAME"
|
||||
|
||||
[ui.admin.header]
|
||||
plane = "Admin Plane"
|
||||
subtitle = "Manage tenants, policies, and operators"
|
||||
|
||||
[ui.admin.nav]
|
||||
api_keys = "API Keys"
|
||||
@@ -1560,6 +1562,7 @@ type = "Type"
|
||||
[ui.dev.clients.type]
|
||||
pkce = "PKCE"
|
||||
private = "Server side App"
|
||||
pkce_headless = "Headless PKCE"
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = "devfront ready"
|
||||
@@ -1596,7 +1599,9 @@ plane = "Dev Plane"
|
||||
subtitle = "Manage your applications"
|
||||
|
||||
[ui.dev.session]
|
||||
auto_extend = "Session expiry controls"
|
||||
active = "Checking expiration..."
|
||||
disabled = "Auto extend disabled"
|
||||
unknown = "Unknown"
|
||||
expired = "Session expired"
|
||||
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
|
||||
|
||||
@@ -70,6 +70,7 @@ greeting = "안녕하세요, {{name}}님"
|
||||
[ui.admin]
|
||||
brand = "Baron 로그인"
|
||||
dev_role_switcher = "🛠 DEV Role Switcher"
|
||||
dev_role_switcher_real = "실제 역할 사용"
|
||||
title = "Admin Control"
|
||||
|
||||
[ui.common]
|
||||
@@ -375,6 +376,7 @@ import_csv = "CSV 임포트"
|
||||
|
||||
[ui.admin.header]
|
||||
plane = "Admin Plane"
|
||||
subtitle = "관리 및 정책 운영"
|
||||
|
||||
[ui.admin.nav]
|
||||
api_keys = "API 키"
|
||||
@@ -463,7 +465,9 @@ plane = "Dev Plane"
|
||||
subtitle = "Manage your applications"
|
||||
|
||||
[ui.dev.session]
|
||||
auto_extend = "세션 만료 관리"
|
||||
active = "세션 활성"
|
||||
disabled = "자동 연장 비활성화"
|
||||
unknown = "알 수 없음"
|
||||
expired = "세션 만료"
|
||||
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
|
||||
@@ -1277,6 +1281,7 @@ type = "유형"
|
||||
[ui.dev.clients.type]
|
||||
private = "Server side App"
|
||||
pkce = "PKCE"
|
||||
pkce_headless = "Headless PKCE"
|
||||
|
||||
[ui.dev.dashboard.badge]
|
||||
consent_guard = "Consent guard ready"
|
||||
|
||||
@@ -70,6 +70,7 @@ greeting = ""
|
||||
[ui.admin]
|
||||
brand = ""
|
||||
dev_role_switcher = ""
|
||||
dev_role_switcher_real = ""
|
||||
title = ""
|
||||
|
||||
[ui.common]
|
||||
@@ -375,6 +376,7 @@ import_csv = ""
|
||||
|
||||
[ui.admin.header]
|
||||
plane = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.admin.nav]
|
||||
api_keys = ""
|
||||
@@ -463,7 +465,9 @@ plane = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.dev.session]
|
||||
auto_extend = ""
|
||||
active = ""
|
||||
disabled = ""
|
||||
unknown = ""
|
||||
expired = ""
|
||||
expiring = ""
|
||||
@@ -1277,6 +1281,7 @@ type = ""
|
||||
[ui.dev.clients.type]
|
||||
pkce = ""
|
||||
private = ""
|
||||
pkce_headless = ""
|
||||
|
||||
[ui.dev.dashboard.badge]
|
||||
consent_guard = ""
|
||||
|
||||
Reference in New Issue
Block a user