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

@@ -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<HTMLDivElement>(null);
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(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() {
<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,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

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

View File

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

View File

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