diff --git a/adminfront/package.json b/adminfront/package.json index 5c4a5309..fbcba5b6 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -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": { diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index eabd0d87..62a5e680 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,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(null); + const isRenewInFlightRef = useRef(false); + const lastRenewAttemptAtRef = useRef(0); + const lastVisitedRouteRef = 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 +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 (
-
+
); } @@ -193,11 +388,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.auto_extend", "세션 만료 관리")} +

+

+ {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 +607,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..ea6405cd 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,30 @@ 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/lib/sessionSliding.test.ts b/adminfront/src/lib/sessionSliding.test.ts new file mode 100644 index 00000000..410ac63e --- /dev/null +++ b/adminfront/src/lib/sessionSliding.test.ts @@ -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); + }); +}); diff --git a/adminfront/src/lib/sessionSliding.ts b/adminfront/src/lib/sessionSliding.ts new file mode 100644 index 00000000..7096e7f3 --- /dev/null +++ b/adminfront/src/lib/sessionSliding.ts @@ -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; +} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index a4b19113..7050826d 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,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" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 92ad133a..6ee85e37 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 키" @@ -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 포탈" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 5766218e..868d4f8d 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -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 = "" diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go index d4b8c335..23086a62 100644 --- a/backend/internal/handler/auth_handler_signup_test.go +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -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) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 1fddc6d9..942eec87 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -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) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 6ed76d90..1855b699 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -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. diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 52a684ed..f019757e 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -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", diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index b173af94..25c8f792 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -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 diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 88dfbc7e..21c43d00 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, @@ -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(null); + const isRenewInFlightRef = useRef(false); + const lastRenewAttemptAtRef = useRef(0); + const lastVisitedRouteRef = useRef(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 | 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 (
-