diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index eabd0d87..699b31bf 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { - BadgeCheck, Building2, ChevronDown, Key, @@ -15,7 +14,7 @@ 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 { fetchMe } from "../../lib/adminApi"; @@ -34,23 +33,37 @@ const staticNavItems = [ function AppLayout() { const auth = useAuth(); const navigate = useNavigate(); + const profileMenuRef = useRef(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(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 +80,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 +100,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 +115,7 @@ function AppLayout() { } return filteredItems; - }, [profile]); + }, [mockRoleOverride, profile]); const handleLogout = () => { if ( @@ -142,14 +153,99 @@ 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); + }; + }, []); + 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 (
-
+
); } @@ -193,11 +289,11 @@ function AppLayout() { ))}
-
+
- {isProfileOpen && ( - <> -
setIsProfileOpen(false)} - onKeyDown={(e) => { - if (e.key === "Escape") setIsProfileOpen(false); - }} - role="button" - tabIndex={-1} - aria-label="Close profile menu" - /> -
-
-

- {profile?.name || auth.user?.profile.name} + {isProfileOpen ? ( +

+

+ {t("ui.dev.profile.menu_title", "Account")} +

+
+
+

+ {profileName}

-

- {profile?.email || auth.user?.profile.email} +

+ {profileEmail}

-
- - {t( - `ui.admin.role.${profile?.role || "user"}`, - profile?.role || "USER", - )} - +
+
+ + {t( + `ui.admin.role.${profileRoleKey}`, + profileRoleKey.toUpperCase(), + )} + +
+
+ +
+
+
+

+ {t("ui.dev.session.expired", "세션 만료")} +

+

+ {isSessionExpiryEnabled + ? sessionText + : t( + "ui.dev.session.disabled", + "세션 만료 표시 비활성화", + )} +

+
+ +
+
+ + {profile?.manageableTenants && + profile.manageableTenants.length > 0 ? ( +
+

+ {t( + "ui.admin.profile.manageable_tenants", + "Manageable Tenants", + )} +

+
+ {profile.manageableTenants.map((tenant) => ( + + ))}
+ ) : null} - {/* Manageable Tenants Section */} - {profile?.manageableTenants && - profile.manageableTenants.length > 0 && ( -
-

- {t( - "ui.admin.profile.manageable_tenants", - "Manageable Tenants", - )} -

-
- {profile.manageableTenants.map((tenant) => ( - - ))} -
-
- )} - - - -
- - )} + + +
+ ) : null}
- - - {timeLeft !== null - ? `Session TTL: ${Math.floor(timeLeft / 60)}m ${timeLeft % 60}s` - : t("msg.admin.session_ttl", "Session TTL: 15m admin")} -
@@ -360,4 +511,3 @@ function AppLayout() { } export default AppLayout; -// force reload diff --git a/adminfront/src/components/layout/RoleSwitcher.tsx b/adminfront/src/components/layout/RoleSwitcher.tsx index 0530f9e8..3a63c2f9 100644 --- a/adminfront/src/components/layout/RoleSwitcher.tsx +++ b/adminfront/src/components/layout/RoleSwitcher.tsx @@ -4,19 +4,19 @@ import { useEffect, useState } from "react"; import { t } from "../../lib/i18n"; const RoleSwitcher: FC = () => { - const [currentRole, setCurrentRole] = useState("super_admin"); + const [currentRole, setCurrentRole] = useState(""); + const [isOverrideEnabled, setIsOverrideEnabled] = useState(false); const [isCollapsed, setIsCollapsed] = useState(() => { 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 && ( - {currentRole.toUpperCase()} + {isOverrideEnabled && currentRole + ? currentRole.toUpperCase() + : "REAL ROLE"} )}
@@ -105,6 +113,26 @@ const RoleSwitcher: FC = () => { marginTop: "4px", }} > + {(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map( (role) => ( diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 857c038d..2c011c6a 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -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()}`; diff --git a/adminfront/src/lib/apiClient.ts b/adminfront/src/lib/apiClient.ts index c4f6b8e2..aae7b63b 100644 --- a/adminfront/src/lib/apiClient.ts +++ b/adminfront/src/lib/apiClient.ts @@ -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; } diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index a4b19113..4f2ecdc6 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -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,12 @@ plane = "Dev Plane" subtitle = "Manage your applications" [ui.dev.session] -active = "Checking expiration..." +active = "Session active" +disabled = "Session expiry display 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" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 92ad133a..6019596f 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -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 키" @@ -1465,12 +1466,11 @@ subtitle = "Manage your applications" [ui.dev.session] active = "세션 활성" +disabled = "세션 만료 표시 비활성화" unknown = "알 수 없음" expired = "세션 만료" expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" -refresh = "세션 만료 시간 갱신" -refreshing = "세션 만료 시간 갱신 중..." [ui.userfront] app_title = "Baron SW 포탈" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 5766218e..a85d4568 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -825,6 +825,7 @@ name = "" [ui.admin.header] plane = "" +subtitle = "" [ui.admin.nav] api_keys = "" @@ -1465,12 +1466,11 @@ subtitle = "" [ui.dev.session] active = "" +disabled = "" unknown = "" expired = "" expiring = "" remaining = "" -refresh = "" -refreshing = "" [ui.userfront] app_title = "" diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 88dfbc7e..4b514438 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { BadgeCheck, + ChevronDown, LogOut, Moon, NotebookTabs, @@ -15,7 +16,6 @@ import { fetchMe } from "../../features/auth/authApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import LanguageSelector from "../common/LanguageSelector"; -import { Badge } from "../ui/badge"; import { Toaster } from "../ui/toaster"; const navItems = [ @@ -42,7 +42,10 @@ function AppLayout() { 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); @@ -100,19 +103,19 @@ function AppLayout() { }; 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 | undefined, ); - - // Use profile.role from API if available, otherwise fallback to local role const displayRoleKey = profile?.role || currentRole; const isDevConsoleAllowed = [ @@ -132,62 +135,55 @@ 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 (
-