1
0
forked from baron/baron-sso

adminfront/devfront 세션 만료 관리 슬라이딩 갱신 로직 추가

This commit is contained in:
2026-04-01 13:52:59 +09:00
parent 32a0efbf1b
commit 391773ac90
11 changed files with 377 additions and 10 deletions

View File

@@ -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<HTMLDivElement>(null);
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(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() {
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.expired", "세션 만료")}
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
: t(
"ui.dev.session.disabled",
"세션 만료 표시 비활성화",
"세션 만료 비활성화",
)}
</p>
</div>

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -1465,8 +1465,9 @@ plane = "Dev Plane"
subtitle = "Manage your applications"
[ui.dev.session]
auto_extend = "세션 만료 관리"
active = "세션 활성"
disabled = "세션 만료 표시 비활성화"
disabled = "세션 만료 비활성화"
unknown = "알 수 없음"
expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"

View File

@@ -1465,6 +1465,7 @@ plane = ""
subtitle = ""
[ui.dev.session]
auto_extend = ""
active = ""
disabled = ""
unknown = ""