diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index e0a1d7bb..469a9362 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -1,5 +1,11 @@ -import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react"; -import { useEffect, useState } from "react"; +import { + BadgeCheck, + LogOut, + Moon, + ShieldHalf, + Sun, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { t } from "../../lib/i18n"; @@ -18,10 +24,14 @@ const navItems = [ function AppLayout() { const auth = useAuth(); const navigate = useNavigate(); + const profileMenuRef = 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 [nowMs, setNowMs] = useState(() => Date.now()); const handleLogout = () => { if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) { @@ -41,10 +51,106 @@ function AppLayout() { window.localStorage.setItem("admin_theme", theme); }, [theme]); + useEffect(() => { + const timer = window.setInterval(() => { + setNowMs(Date.now()); + }, 1000); + return () => { + window.clearInterval(timer); + }; + }, []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + profileMenuRef.current && + !profileMenuRef.current.contains(event.target as Node) + ) { + setIsProfileMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + const toggleTheme = () => { setTheme((prev) => (prev === "light" ? "dark" : "light")); }; + const profileName = + 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 = + auth.user?.profile?.email?.toString().trim() || + t("ui.dev.profile.unknown_email", "unknown@example.com"); + const profileInitial = profileName.charAt(0).toUpperCase(); + 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 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); + } + }; + return (
diff --git a/docker/ory/hydra/hydra.yml b/docker/ory/hydra/hydra.yml index 82f9fb37..7bf79b10 100644 --- a/docker/ory/hydra/hydra.yml +++ b/docker/ory/hydra/hydra.yml @@ -92,3 +92,7 @@ oidc: salt: youReallyNeedToChangeThis dynamic_client_registration: enabled: true + +ttl: + access_token: 15m + id_token: 15m