diff --git a/adminfront/src/components/common/LanguageSelector.tsx b/adminfront/src/components/common/LanguageSelector.tsx index 7f905cd0..9612b744 100644 --- a/adminfront/src/components/common/LanguageSelector.tsx +++ b/adminfront/src/components/common/LanguageSelector.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { t } from "../../lib/i18n"; const LOCALE_STORAGE_KEY = "locale"; +const LOCALE_CHANGED_EVENT = "baron_locale_changed"; const SUPPORTED_LOCALES = ["ko", "en"] as const; type Locale = (typeof SUPPORTED_LOCALES)[number]; @@ -34,6 +35,10 @@ function LanguageSelector() { } window.localStorage.setItem(LOCALE_STORAGE_KEY, next); setLocale(next); + if (import.meta.env.MODE === "development") { + window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT)); + return; + } window.location.reload(); }; diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index d775e05b..d8cdeca4 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -43,6 +43,9 @@ import { 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", @@ -127,6 +130,7 @@ function AppLayout() { 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 }) @@ -139,8 +143,9 @@ function AppLayout() { : null; const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [isProfileOpen, setIsProfileOpen] = useState(false); - const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState( - readShellSessionExpiryEnabled, + const [, setDevelopmentRenderRevision] = useState(0); + const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => + readShellSessionExpiryEnabled(!isDevelopmentRuntime), ); const { data: profile, @@ -290,6 +295,27 @@ function AppLayout() { 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, + ); + }; + }, [isDevelopmentRuntime]); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -355,6 +381,10 @@ function AppLayout() { ]); useEffect(() => { + if (isDevelopmentRuntime) { + return; + } + const maybeKeepSessionAlive = async () => { const now = Date.now(); if ( @@ -397,6 +427,7 @@ function AppLayout() { auth.isAuthenticated, auth.isLoading, auth.user?.expires_at, + isDevelopmentRuntime, isSessionExpiryEnabled, ]); diff --git a/adminfront/src/components/layout/RoleSwitcher.tsx b/adminfront/src/components/layout/RoleSwitcher.tsx index ea6405cd..38c122dd 100644 --- a/adminfront/src/components/layout/RoleSwitcher.tsx +++ b/adminfront/src/components/layout/RoleSwitcher.tsx @@ -3,6 +3,8 @@ import type { FC } from "react"; import { useEffect, useState } from "react"; import { t } from "../../lib/i18n"; +const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed"; + const RoleSwitcher: FC = () => { const [currentRole, setCurrentRole] = useState(""); const [isOverrideEnabled, setIsOverrideEnabled] = useState(false); @@ -31,13 +33,13 @@ const RoleSwitcher: FC = () => { window.localStorage.setItem("X-Mock-Role-Enabled", "true"); setCurrentRole(role); setIsOverrideEnabled(true); - window.location.reload(); + window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT)); }; const clearRoleOverride = () => { window.localStorage.removeItem("X-Mock-Role-Enabled"); setIsOverrideEnabled(false); - window.location.reload(); + window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT)); }; if (import.meta.env.MODE === "production") return null; diff --git a/adminfront/src/features/auth/LoginPage.tsx b/adminfront/src/features/auth/LoginPage.tsx index cdd807ab..b0fcabf1 100644 --- a/adminfront/src/features/auth/LoginPage.tsx +++ b/adminfront/src/features/auth/LoginPage.tsx @@ -84,8 +84,11 @@ function LoginPage() { variant="ghost" className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent" onClick={() => { - window.location.href = - window.location.origin + window.location.pathname; + void auth.signinRedirect({ + state: { + returnTo, + }, + }); }} > 다시 시도하기 diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index 3abdbd43..79f6c966 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -194,7 +194,14 @@ export function UserGroupDetailPage() { "Not found"}

-
diff --git a/adminfront/src/lib/apiClient.ts b/adminfront/src/lib/apiClient.ts index 68f94a1c..7d2a9b80 100644 --- a/adminfront/src/lib/apiClient.ts +++ b/adminfront/src/lib/apiClient.ts @@ -1,5 +1,8 @@ import axios from "axios"; import { shouldStartLoginRedirect } from "../../../common/core/auth"; +import { + shouldSuppressDevelopmentSessionRedirect, +} from "../../../common/core/session"; import { userManager } from "./auth"; let isRedirectingToLogin = false; @@ -42,6 +45,17 @@ apiClient.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { + if ( + shouldSuppressDevelopmentSessionRedirect({ + appMode: import.meta.env.MODE, + }) + ) { + console.warn( + "[apiClient] 401 Unauthorized detected, but development session redirects are disabled.", + ); + return Promise.reject(error); + } + console.warn( "[apiClient] 401 Unauthorized detected. Clearing session state.", ); diff --git a/adminfront/src/lib/sessionSliding.test.ts b/adminfront/src/lib/sessionSliding.test.ts index 6e234b63..879b574a 100644 --- a/adminfront/src/lib/sessionSliding.test.ts +++ b/adminfront/src/lib/sessionSliding.test.ts @@ -1,10 +1,23 @@ import { describe, expect, it } from "vitest"; import { + readSessionExpiryEnabled, SESSION_RENEW_THRESHOLD_MS, shouldAttemptSlidingSessionRenew, + shouldSuppressDevelopmentSessionRedirect, shouldAttemptUnlimitedSessionRenew, + writeSessionExpiryEnabled, } from "./sessionSliding"; +function memoryStorage(initialValue: string | null = null) { + let value = initialValue; + return { + getItem: (_key: string) => value, + setItem: (_key: string, nextValue: string) => { + value = nextValue; + }, + }; +} + describe("shouldAttemptSlidingSessionRenew", () => { const nowMs = 1_700_000_000_000; @@ -124,3 +137,43 @@ describe("shouldAttemptUnlimitedSessionRenew", () => { ).toBe(false); }); }); + +describe("session expiry development preference", () => { + it("defaults session expiry management off in development when no preference is stored", () => { + expect( + readSessionExpiryEnabled({ + defaultEnabled: false, + storage: memoryStorage(null), + }), + ).toBe(false); + }); + + it("keeps explicit stored preference over the development default", () => { + const storage = memoryStorage(null); + + writeSessionExpiryEnabled(true, storage); + + expect( + readSessionExpiryEnabled({ + defaultEnabled: false, + storage, + }), + ).toBe(true); + }); + + it("suppresses login redirects only in development with disabled session expiry management", () => { + expect( + shouldSuppressDevelopmentSessionRedirect({ + appMode: "development", + storage: memoryStorage(null), + }), + ).toBe(true); + + expect( + shouldSuppressDevelopmentSessionRedirect({ + appMode: "production", + storage: memoryStorage(null), + }), + ).toBe(false); + }); +}); diff --git a/adminfront/src/lib/sessionSliding.ts b/adminfront/src/lib/sessionSliding.ts index 389fcef1..5cc4ff72 100644 --- a/adminfront/src/lib/sessionSliding.ts +++ b/adminfront/src/lib/sessionSliding.ts @@ -1,6 +1,9 @@ export { DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS, DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS, + readSessionExpiryEnabled, shouldAttemptSlidingSessionRenew, + shouldSuppressDevelopmentSessionRedirect, shouldAttemptUnlimitedSessionRenew, + writeSessionExpiryEnabled, } from "../../../common/core/session"; diff --git a/common/core/session/index.ts b/common/core/session/index.ts index 18ef3e07..dfe33bc1 100644 --- a/common/core/session/index.ts +++ b/common/core/session/index.ts @@ -1,5 +1,9 @@ export const DEFAULT_SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000; export const DEFAULT_SESSION_RENEW_THROTTLE_MS = 30 * 1000; +export const SESSION_EXPIRY_STORAGE_KEY = "baron_session_expiry_enabled"; + +type SessionExpiryReadableStorage = Pick; +type SessionExpiryWritableStorage = Pick; export type SessionRenewDecisionParams = { expiresAtSec?: number | null; @@ -13,6 +17,51 @@ export type SessionRenewDecisionParams = { throttleMs?: number; }; +export type SessionExpiryPreferenceParams = { + defaultEnabled?: boolean; + storage?: SessionExpiryReadableStorage | null; +}; + +export type DevelopmentSessionRedirectParams = { + appMode: string; + defaultEnabled?: boolean; + storage?: SessionExpiryReadableStorage | null; +}; + +function browserStorage() { + if (typeof window === "undefined") { + return null; + } + + return window.localStorage; +} + +export function readSessionExpiryEnabled({ + defaultEnabled = true, + storage = browserStorage(), +}: SessionExpiryPreferenceParams = {}) { + const stored = storage?.getItem(SESSION_EXPIRY_STORAGE_KEY) ?? null; + return stored === null ? defaultEnabled : stored !== "false"; +} + +export function writeSessionExpiryEnabled( + isEnabled: boolean, + storage: SessionExpiryWritableStorage | null = browserStorage(), +) { + storage?.setItem(SESSION_EXPIRY_STORAGE_KEY, String(isEnabled)); +} + +export function shouldSuppressDevelopmentSessionRedirect({ + appMode, + defaultEnabled = appMode !== "development", + storage = browserStorage(), +}: DevelopmentSessionRedirectParams) { + return ( + appMode === "development" && + !readSessionExpiryEnabled({ defaultEnabled, storage }) + ); +} + function hasRenewPreconditions({ isAuthenticated, isLoading, diff --git a/common/shell/index.ts b/common/shell/index.ts index 2c794547..e394cefc 100644 --- a/common/shell/index.ts +++ b/common/shell/index.ts @@ -1,3 +1,9 @@ +import { + SESSION_EXPIRY_STORAGE_KEY, + readSessionExpiryEnabled, + writeSessionExpiryEnabled, +} from "../core/session"; + export type ShellTheme = "light" | "dark"; export type ShellTranslator = ( @@ -20,8 +26,7 @@ type ShellProfileSummaryParams = { }; export const SHELL_THEME_STORAGE_KEY = "admin_theme"; -export const SHELL_SESSION_EXPIRY_STORAGE_KEY = - "baron_session_expiry_enabled"; +export const SHELL_SESSION_EXPIRY_STORAGE_KEY = SESSION_EXPIRY_STORAGE_KEY; export { AppSidebar } from "./AppSidebar"; export type { ShellSidebarNavItem } from "./AppSidebar"; export { shellLayoutClasses } from "./layout"; @@ -39,15 +44,12 @@ export function applyShellTheme(theme: ShellTheme) { window.localStorage.setItem(SHELL_THEME_STORAGE_KEY, theme); } -export function readShellSessionExpiryEnabled() { - return window.localStorage.getItem(SHELL_SESSION_EXPIRY_STORAGE_KEY) !== "false"; +export function readShellSessionExpiryEnabled(defaultEnabled = true) { + return readSessionExpiryEnabled({ defaultEnabled }); } export function writeShellSessionExpiryEnabled(isEnabled: boolean) { - window.localStorage.setItem( - SHELL_SESSION_EXPIRY_STORAGE_KEY, - String(isEnabled), - ); + writeSessionExpiryEnabled(isEnabled); } export function buildShellProfileSummary({ diff --git a/devfront/src/components/common/LanguageSelector.tsx b/devfront/src/components/common/LanguageSelector.tsx index 7f905cd0..9612b744 100644 --- a/devfront/src/components/common/LanguageSelector.tsx +++ b/devfront/src/components/common/LanguageSelector.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { t } from "../../lib/i18n"; const LOCALE_STORAGE_KEY = "locale"; +const LOCALE_CHANGED_EVENT = "baron_locale_changed"; const SUPPORTED_LOCALES = ["ko", "en"] as const; type Locale = (typeof SUPPORTED_LOCALES)[number]; @@ -34,6 +35,10 @@ function LanguageSelector() { } window.localStorage.setItem(LOCALE_STORAGE_KEY, next); setLocale(next); + if (import.meta.env.MODE === "development") { + window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT)); + return; + } window.location.reload(); }; diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index c0561400..34e453b9 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -35,6 +35,8 @@ import { import LanguageSelector from "../common/LanguageSelector"; import { Toaster } from "../ui/toaster"; +const LOCALE_CHANGED_EVENT = "baron_locale_changed"; + const navItems: ShellSidebarNavItem[] = [ { labelKey: "ui.dev.nav.overview", @@ -113,10 +115,12 @@ function AppLayout() { const isRenewInFlightRef = useRef(false); const lastRenewAttemptAtRef = useRef(0); const lastVisitedRouteRef = useRef(null); + const isDevelopmentRuntime = import.meta.env.MODE === "development"; const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); - const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState( - readShellSessionExpiryEnabled, + const [, setDevelopmentRenderRevision] = useState(0); + const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => + readShellSessionExpiryEnabled(!isDevelopmentRuntime), ); const hasAccessToken = Boolean(auth.user?.access_token); const { data: profile } = useQuery({ @@ -140,6 +144,22 @@ function AppLayout() { applyShellTheme(theme); }, [theme]); + useEffect(() => { + if (!isDevelopmentRuntime) { + return; + } + + const rerenderDevelopmentShell = () => { + setDevelopmentRenderRevision((value) => value + 1); + }; + + window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell); + + return () => { + window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell); + }; + }, [isDevelopmentRuntime]); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -205,6 +225,10 @@ function AppLayout() { ]); useEffect(() => { + if (isDevelopmentRuntime) { + return; + } + const maybeKeepSessionAlive = async () => { const now = Date.now(); if ( @@ -247,6 +271,7 @@ function AppLayout() { auth.isAuthenticated, auth.isLoading, auth.user?.expires_at, + isDevelopmentRuntime, isSessionExpiryEnabled, ]); diff --git a/devfront/src/lib/apiClient.ts b/devfront/src/lib/apiClient.ts index f90eb970..71d1959c 100644 --- a/devfront/src/lib/apiClient.ts +++ b/devfront/src/lib/apiClient.ts @@ -1,5 +1,8 @@ import axios from "axios"; import { shouldStartLoginRedirect } from "../../../common/core/auth"; +import { + shouldSuppressDevelopmentSessionRedirect, +} from "../../../common/core/session"; import { userManager } from "./auth"; let isRedirectingToLogin = false; @@ -42,8 +45,22 @@ apiClient.interceptors.response.use( message.includes("invalid session") || message.includes("token is not active"))); + if (!shouldRedirectToLogin) { + return Promise.reject(error); + } + + if ( + shouldSuppressDevelopmentSessionRedirect({ + appMode: import.meta.env.MODE, + }) + ) { + console.warn( + "[apiClient] Auth failure detected, but development session redirects are disabled.", + ); + return Promise.reject(error); + } + if ( - shouldRedirectToLogin && shouldStartLoginRedirect({ pathname: window.location.pathname, isRedirecting: isRedirectingToLogin, diff --git a/orgfront/src/components/common/LanguageSelector.tsx b/orgfront/src/components/common/LanguageSelector.tsx index 7f905cd0..9612b744 100644 --- a/orgfront/src/components/common/LanguageSelector.tsx +++ b/orgfront/src/components/common/LanguageSelector.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { t } from "../../lib/i18n"; const LOCALE_STORAGE_KEY = "locale"; +const LOCALE_CHANGED_EVENT = "baron_locale_changed"; const SUPPORTED_LOCALES = ["ko", "en"] as const; type Locale = (typeof SUPPORTED_LOCALES)[number]; @@ -34,6 +35,10 @@ function LanguageSelector() { } window.localStorage.setItem(LOCALE_STORAGE_KEY, next); setLocale(next); + if (import.meta.env.MODE === "development") { + window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT)); + return; + } window.location.reload(); }; diff --git a/orgfront/src/components/layout/AppLayout.tsx b/orgfront/src/components/layout/AppLayout.tsx index 9cd392f2..83f98b13 100644 --- a/orgfront/src/components/layout/AppLayout.tsx +++ b/orgfront/src/components/layout/AppLayout.tsx @@ -32,6 +32,8 @@ import { import LanguageSelector from "../common/LanguageSelector"; import { Toaster } from "../ui/toaster"; +const LOCALE_CHANGED_EVENT = "baron_locale_changed"; + const navItems = [ { labelKey: "ui.dev.nav.clients", @@ -97,10 +99,12 @@ function AppLayout() { const isRenewInFlightRef = useRef(false); const lastRenewAttemptAtRef = useRef(0); const lastVisitedRouteRef = useRef(null); + const isDevelopmentRuntime = import.meta.env.MODE === "development"; const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); - const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState( - readShellSessionExpiryEnabled, + const [, setDevelopmentRenderRevision] = useState(0); + const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => + readShellSessionExpiryEnabled(!isDevelopmentRuntime), ); const hasAccessToken = Boolean(auth.user?.access_token); const { data: profile } = useQuery({ @@ -120,6 +124,22 @@ function AppLayout() { applyShellTheme(theme); }, [theme]); + useEffect(() => { + if (!isDevelopmentRuntime) { + return; + } + + const rerenderDevelopmentShell = () => { + setDevelopmentRenderRevision((value) => value + 1); + }; + + window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell); + + return () => { + window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell); + }; + }, [isDevelopmentRuntime]); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -185,6 +205,10 @@ function AppLayout() { ]); useEffect(() => { + if (isDevelopmentRuntime) { + return; + } + const maybeKeepSessionAlive = async () => { const now = Date.now(); if ( @@ -227,6 +251,7 @@ function AppLayout() { auth.isAuthenticated, auth.isLoading, auth.user?.expires_at, + isDevelopmentRuntime, isSessionExpiryEnabled, ]); diff --git a/orgfront/src/features/auth/AuthGuard.tsx b/orgfront/src/features/auth/AuthGuard.tsx index bf2cfd6a..a964548b 100644 --- a/orgfront/src/features/auth/AuthGuard.tsx +++ b/orgfront/src/features/auth/AuthGuard.tsx @@ -1,10 +1,11 @@ import { useAuth } from "react-oidc-context"; -import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom"; import { t } from "../../lib/i18n"; export default function AuthGuard() { const auth = useAuth(); const location = useLocation(); + const navigate = useNavigate(); const searchParams = new URLSearchParams(location.search); const shareToken = searchParams.get("token"); const isPlaywrightBypass = @@ -57,7 +58,7 @@ export default function AuthGuard() { className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90" onClick={() => { auth.removeUser(); - window.location.href = "/login"; + navigate("/login"); }} > {t("ui.common.back_to_login", "로그인으로 돌아가기")} diff --git a/orgfront/src/lib/apiClient.ts b/orgfront/src/lib/apiClient.ts index 767e277e..e8f07ecd 100644 --- a/orgfront/src/lib/apiClient.ts +++ b/orgfront/src/lib/apiClient.ts @@ -1,5 +1,8 @@ import axios from "axios"; import { shouldStartLoginRedirect } from "../../../common/core/auth"; +import { + shouldSuppressDevelopmentSessionRedirect, +} from "../../../common/core/session"; import { userManager } from "./auth"; let isRedirectingToLogin = false; @@ -42,8 +45,22 @@ apiClient.interceptors.response.use( message.includes("invalid session") || message.includes("token is not active"))); + if (!shouldRedirectToLogin) { + return Promise.reject(error); + } + + if ( + shouldSuppressDevelopmentSessionRedirect({ + appMode: import.meta.env.MODE, + }) + ) { + console.warn( + "[apiClient] Auth failure detected, but development session redirects are disabled.", + ); + return Promise.reject(error); + } + if ( - shouldRedirectToLogin && shouldStartLoginRedirect({ pathname: window.location.pathname, isRedirecting: isRedirectingToLogin,