From 391773ac90778b66c8bf9b4dba72d084a3806afd Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 1 Apr 2026 13:52:59 +0900 Subject: [PATCH] =?UTF-8?q?adminfront/devfront=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EA=B4=80=EB=A6=AC=20=EC=8A=AC=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=94=A9=20=EA=B0=B1=EC=8B=A0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.tsx | 109 +++++++++++++++++- adminfront/src/lib/sessionSliding.test.ts | 65 +++++++++++ adminfront/src/lib/sessionSliding.ts | 45 ++++++++ adminfront/src/locales/en.toml | 3 +- adminfront/src/locales/ko.toml | 3 +- adminfront/src/locales/template.toml | 1 + devfront/src/components/layout/AppLayout.tsx | 109 +++++++++++++++++- devfront/src/lib/sessionSliding.ts | 45 ++++++++ devfront/src/locales/en.toml | 3 +- devfront/src/locales/ko.toml | 3 +- devfront/src/locales/template.toml | 1 + 11 files changed, 377 insertions(+), 10 deletions(-) create mode 100644 adminfront/src/lib/sessionSliding.test.ts create mode 100644 adminfront/src/lib/sessionSliding.ts create mode 100644 devfront/src/lib/sessionSliding.ts diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 699b31bf..caa86328 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -16,9 +16,10 @@ import { import * as React 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"; @@ -32,8 +33,12 @@ 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 }) @@ -169,6 +174,104 @@ 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")); }; @@ -397,14 +500,14 @@ function AppLayout() {

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

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

diff --git a/adminfront/src/lib/sessionSliding.test.ts b/adminfront/src/lib/sessionSliding.test.ts new file mode 100644 index 00000000..7d7441e1 --- /dev/null +++ b/adminfront/src/lib/sessionSliding.test.ts @@ -0,0 +1,65 @@ +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 4f2ecdc6..7050826d 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -1466,8 +1466,9 @@ plane = "Dev Plane" subtitle = "Manage your applications" [ui.dev.session] +auto_extend = "Session expiry" active = "Session active" -disabled = "Session expiry display disabled" +disabled = "Session expiry disabled" unknown = "Unknown" expired = "Session expired" expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 6019596f..6ee85e37 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -1465,8 +1465,9 @@ plane = "Dev Plane" subtitle = "Manage your applications" [ui.dev.session] +auto_extend = "세션 만료 관리" active = "세션 활성" -disabled = "세션 만료 표시 비활성화" +disabled = "세션 만료 비활성화" unknown = "알 수 없음" expired = "세션 만료" expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index a85d4568..f22098c4 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1465,6 +1465,7 @@ plane = "" subtitle = "" [ui.dev.session] +auto_extend = "" active = "" disabled = "" unknown = "" diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 4b514438..875344f3 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -11,10 +11,11 @@ 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 { Toaster } from "../ui/toaster"; @@ -35,8 +36,12 @@ 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"; @@ -98,6 +103,104 @@ 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")); }; @@ -346,14 +449,14 @@ function AppLayout() {

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

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

diff --git a/devfront/src/lib/sessionSliding.ts b/devfront/src/lib/sessionSliding.ts new file mode 100644 index 00000000..7096e7f3 --- /dev/null +++ b/devfront/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/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 8096aa10..bed02717 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -1495,8 +1495,9 @@ plane = "Dev Plane" subtitle = "Manage your applications" [ui.dev.session] +auto_extend = "Session expiry" active = "Session active" -disabled = "Session expiry display disabled" +disabled = "Session expiry disabled" unknown = "Unknown" expired = "Session expired" expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 3d47076e..1a790075 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -1495,8 +1495,9 @@ plane = "Dev Plane" subtitle = "Manage your applications" [ui.dev.session] +auto_extend = "세션 만료 관리" active = "세션 활성" -disabled = "세션 만료 표시 비활성화" +disabled = "세션 만료 비활성화" unknown = "알 수 없음" expired = "세션 만료" expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index d8a73ffa..c4c08089 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -1493,6 +1493,7 @@ plane = "" subtitle = "" [ui.dev.session] +auto_extend = "" active = "" disabled = "" unknown = ""