import { useQuery } from "@tanstack/react-query"; import { Building2, ChevronDown, Database, Key, KeyRound, LayoutDashboard, LogOut, Moon, Network, NotebookTabs, ShieldCheck, ShieldHalf, Sun, User as UserIcon, Users, } from "lucide-react"; import * as React from "react"; import { useEffect, useRef, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { AppSidebar, applyShellTheme, buildShellProfileSummary, buildShellSessionStatus, readShellSessionExpiryEnabled, readShellTheme, type ShellSidebarNavItem, type ShellTranslator, shellLayoutClasses, writeShellSessionExpiryEnabled, } from "../../../../common/shell"; import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; import { fetchMe } from "../../lib/adminApi"; import { debugLog } from "../../lib/debugLog"; import { t } from "../../lib/i18n"; import { isSuperAdminRole } from "../../lib/roles"; import { shouldAttemptSlidingSessionRenew, shouldAttemptUnlimitedSessionRenew, } from "../../lib/sessionSliding"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; const LOCALE_CHANGED_EVENT = "baron_locale_changed"; const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed"; const staticNavItems: ShellSidebarNavItem[] = [ { labelKey: "ui.admin.nav.overview", labelFallback: "Overview", to: "/", icon: LayoutDashboard, end: true, }, { labelKey: "ui.admin.nav.users", labelFallback: "Users", to: "/users", icon: Users, }, { labelKey: "ui.admin.nav.api_keys", labelFallback: "API Keys", to: "/api-keys", icon: Key, }, { labelKey: "ui.admin.nav.audit_logs", labelFallback: "Audit Logs", to: "/audit-logs", icon: NotebookTabs, }, { labelKey: "ui.admin.nav.auth_guard", labelFallback: "Auth Guard", to: "/auth", icon: KeyRound, }, ]; type SessionStatusProps = { expiresAtSec?: number | null; t: ShellTranslator; }; function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) { const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { const timer = window.setInterval(() => { setNowMs(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; }, []); return buildShellSessionStatus({ expiresAtSec, nowMs, t }); } function SessionStatusBadge(props: SessionStatusProps) { const sessionStatus = useSessionStatus(props); return ( {sessionStatus.text} ); } function SessionStatusText(props: SessionStatusProps) { const sessionStatus = useSessionStatus(props); return <>{sessionStatus.text}; } function AppLayout() { const auth = useAuth(); const location = useLocation(); const navigate = useNavigate(); const profileMenuRef = useRef(null); const isRenewInFlightRef = useRef(false); const lastRenewAttemptAtRef = useRef(0); const lastVisitedRouteRef = useRef(null); const isDevelopmentRuntime = import.meta.env.MODE === "development"; 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">(readShellTheme); const [isProfileOpen, setIsProfileOpen] = useState(false); const [, setDevelopmentRenderRevision] = useState(0); const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => readShellSessionExpiryEnabled(!isDevelopmentRuntime), ); const { data: profile, isLoading: isProfileLoading, error: profileError, } = useQuery({ queryKey: ["me"], queryFn: async () => { debugLog("[AppLayout] Fetching profile..."); try { const data = await fetchMe(); debugLog("[AppLayout] Profile fetched successfully:", data.email); return data; } catch (err) { console.error("[AppLayout] Failed to fetch profile:", err); throw err; } }, enabled: (auth.isAuthenticated && !auth.isLoading) || import.meta.env.MODE === "development" || (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) ._IS_TEST_MODE === true, }); const navItems = React.useMemo(() => { const items = [...staticNavItems]; const isTest = (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) ._IS_TEST_MODE === true; const effectiveRole = mockRoleOverride || profile?.role; const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole); const isTenantAdmin = effectiveRole === "tenant_admin"; const manageableCount = profile?.manageableTenants?.length ?? 0; const orgfrontUrl = buildAuthenticatedOrgChartUrl( import.meta.env.ORGFRONT_URL || "http://localhost:5175", { includeInternal: true }, ); const filteredItems = items.filter((item) => { if (isTest) return true; if (item.to === "/api-keys") return isSuperAdmin; return true; }); if (isSuperAdmin) { filteredItems.splice(1, 0, { labelKey: "ui.admin.nav.tenants", labelFallback: "Tenants", to: "/tenants", icon: Building2, }); filteredItems.splice(2, 0, { labelKey: "ui.admin.nav.org_chart", labelFallback: "Org Chart", to: orgfrontUrl, icon: Network, isExternal: true, }); filteredItems.splice(4, 0, { labelKey: "ui.admin.nav.user_projection", labelFallback: "User Projection", to: "/system/projections/users", icon: Database, }); filteredItems.splice(5, 0, { labelKey: "ui.admin.nav.data_integrity", labelFallback: "Data Integrity", to: "/system/data-integrity", icon: ShieldCheck, }); } else if (isTenantAdmin || manageableCount > 0) { if (manageableCount <= 1 && profile?.tenantId) { filteredItems.splice(1, 0, { labelKey: "ui.admin.nav.my_tenant", labelFallback: "My Tenant", to: `/tenants/${profile.tenantId}`, icon: Building2, }); } else if (manageableCount > 1) { filteredItems.splice(1, 0, { labelKey: "ui.admin.nav.tenants", labelFallback: "Tenants", to: "/tenants", icon: Building2, }); } filteredItems.splice( manageableCount <= 1 && profile?.tenantId ? 2 : 2, 0, { labelKey: "ui.admin.nav.org_chart", labelFallback: "Org Chart", to: orgfrontUrl, icon: Network, isExternal: true, }, ); } else { // 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다. filteredItems.splice(1, 0, { labelKey: "ui.admin.nav.org_chart", labelFallback: "Org Chart", to: orgfrontUrl, icon: Network, isExternal: true, }); } return filteredItems; }, [mockRoleOverride, profile]); const handleLogout = () => { if ( window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?")) ) { window.localStorage.removeItem("admin_session"); auth.removeUser(); navigate("/login"); } }; useEffect(() => { const isTest = (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) ._IS_TEST_MODE === true; debugLog("[AppLayout] Auth state check:", { isLoading: auth.isLoading, isAuthenticated: auth.isAuthenticated, isTest, }); if (!auth.isLoading && !auth.isAuthenticated && !isTest) { console.warn("[AppLayout] Not authenticated, redirecting to /login"); navigate("/login"); } }, [auth.isLoading, auth.isAuthenticated, navigate]); useEffect(() => { if (auth.user?.access_token) { window.localStorage.setItem("admin_session", auth.user.access_token); } }, [auth.user]); useEffect(() => { applyShellTheme(theme); }, [theme]); useEffect(() => { if (!isDevelopmentRuntime) { return; } const rerenderDevelopmentShell = () => { setDevelopmentRenderRevision((value) => value + 1); }; window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell); window.addEventListener(DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell); return () => { window.removeEventListener( LOCALE_CHANGED_EVENT, rerenderDevelopmentShell, ); window.removeEventListener( DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell, ); }; }, []); 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(() => { if (isDevelopmentRuntime) { return; } const maybeKeepSessionAlive = async () => { const now = Date.now(); if ( !shouldAttemptUnlimitedSessionRenew({ 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 timer = window.setInterval(() => { void maybeKeepSessionAlive(); }, 30_000); void maybeKeepSessionAlive(); return () => { window.clearInterval(timer); }; }, [ 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 profileSummary = buildShellProfileSummary({ profileName: profile?.name || auth.user?.profile.name?.toString() || auth.user?.profile.preferred_username?.toString(), profileEmail: profile?.email || auth.user?.profile.email?.toString(), fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"), fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"), }); const profileRoleKey = mockRoleOverride || profile?.role || "user"; const handleSessionExpiryToggle = () => { setIsSessionExpiryEnabled((prev) => { const next = !prev; writeShellSessionExpiryEnabled(next); return next; }); }; const sidebarNavContent = (
{navItems.map((item) => { const { labelKey, labelFallback, to, icon: Icon, isExternal } = item; if (isExternal) { return ( {t(labelKey, labelFallback)} ); } return ( [ shellLayoutClasses.navItemBase, item.isActive !== undefined ? item.isActive ? shellLayoutClasses.navItemActive : shellLayoutClasses.navItemIdle : isActive ? shellLayoutClasses.navItemActive : shellLayoutClasses.navItemIdle, ].join(" ") } > {t(labelKey, labelFallback)} ); })}
); const sidebarFooterContent = (
); if (auth.isLoading) { return (
); } return (
} navContent={sidebarNavContent} footerContent={sidebarFooterContent} />

{t("ui.admin.header.plane", "ADMIN PLANE")}

{t("ui.admin.header.subtitle", "Manage your organization")}
{isSessionExpiryEnabled ? ( ) : null}
{isProfileOpen ? (

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

{profileSummary.name}

{profileSummary.email}

{t( `ui.shell.role.${profileRoleKey}`, profileRoleKey.toUpperCase(), )}

{t( "ui.shell.session.auto_extend", "세션 만료 관리", )}

{isSessionExpiryEnabled ? ( ) : ( t( "ui.shell.session.disabled", "세션 만료 비활성화", ) )}

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

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

{profile.manageableTenants.map((tenant) => ( ))}
) : null}
) : null}
); } export default AppLayout;