1
0
forked from baron/baron-sso

Merge pull request 'feature/df-headless-login' (#499) from feature/df-headless-login into dev

Reviewed-on: baron/baron-sso#499
This commit is contained in:
2026-04-01 15:13:16 +09:00
26 changed files with 898 additions and 265 deletions

View File

@@ -13,9 +13,9 @@
"lint:fix": "biome check . --write", "lint:fix": "biome check . --write",
"format": "biome format . --write", "format": "biome format . --write",
"preview": "vite preview", "preview": "vite preview",
"test": "playwright test", "test": "npx playwright test",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:ui": "playwright test --ui", "test:ui": "npx playwright test --ui",
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js" "i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
}, },
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
BadgeCheck,
Building2, Building2,
ChevronDown, ChevronDown,
Key, Key,
@@ -15,11 +14,12 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context"; 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 { fetchMe } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector"; import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher"; import RoleSwitcher from "./RoleSwitcher";
@@ -33,24 +33,41 @@ const staticNavItems = [
function AppLayout() { function AppLayout() {
const auth = useAuth(); const auth = useAuth();
const location = useLocation();
const navigate = useNavigate(); 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 })
._IS_TEST_MODE === true;
const isMockRoleEnabled =
isDevRoleOverrideEnabled &&
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRoleOverride = isMockRoleEnabled
? window.localStorage.getItem("X-Mock-Role")
: null;
const [theme, setTheme] = useState<"light" | "dark">(() => { const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme"); const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light"; return stored === "dark" ? "dark" : "light";
}); });
const [isProfileOpen, setIsProfileOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false);
const [timeLeft, setTimeLeft] = useState<number | null>(null); const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
const expiresAt = auth.user?.expires_at; const stored = window.localStorage.getItem("baron_session_expiry_enabled");
return stored !== "false";
});
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
if (!expiresAt) return; const timer = window.setInterval(() => {
const updateTimer = () => { setNowMs(Date.now());
setTimeLeft(Math.max(0, Math.floor(expiresAt - Date.now() / 1000))); }, 1000);
return () => {
window.clearInterval(timer);
}; };
updateTimer(); }, []);
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
const { data: profile } = useQuery({ const { data: profile } = useQuery({
queryKey: ["me"], queryKey: ["me"],
@@ -67,10 +84,10 @@ function AppLayout() {
const isTest = const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true; ._IS_TEST_MODE === true;
const effectiveRole = mockRoleOverride || profile?.role;
// 테스트 모드이면 profile이 없어도 super_admin으로 간주하여 모든 메뉴 렌더링 const isSuperAdmin = isTest || effectiveRole === "super_admin";
const isSuperAdmin = isTest || profile?.role === "super_admin"; const isTenantAdmin = effectiveRole === "tenant_admin";
const isTenantAdmin = profile?.role === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0; const manageableCount = profile?.manageableTenants?.length ?? 0;
const filteredItems = items.filter((item) => { const filteredItems = items.filter((item) => {
@@ -87,14 +104,12 @@ function AppLayout() {
}); });
} else if (isTenantAdmin || manageableCount > 0) { } else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) { if (manageableCount <= 1 && profile?.tenantId) {
// Direct link if only one (or zero in array but has tenantId) tenant
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {
label: "ui.admin.nav.my_tenant", label: "ui.admin.nav.my_tenant",
to: `/tenants/${profile.tenantId}`, to: `/tenants/${profile.tenantId}`,
icon: Building2, icon: Building2,
}); });
} else if (manageableCount > 1) { } else if (manageableCount > 1) {
// Show list menu if multiple tenants
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants", label: "ui.admin.nav.tenants",
to: "/tenants", to: "/tenants",
@@ -104,7 +119,7 @@ function AppLayout() {
} }
return filteredItems; return filteredItems;
}, [profile]); }, [mockRoleOverride, profile]);
const handleLogout = () => { const handleLogout = () => {
if ( if (
@@ -142,14 +157,194 @@ function AppLayout() {
window.localStorage.setItem("admin_theme", theme); window.localStorage.setItem("admin_theme", theme);
}, [theme]); }, [theme]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
profileMenuRef.current &&
!profileMenuRef.current.contains(event.target as Node)
) {
setIsProfileOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
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 = () => { const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light")); setTheme((prev) => (prev === "light" ? "dark" : "light"));
}; };
const profileName =
profile?.name?.trim() ||
auth.user?.profile.name?.toString().trim() ||
auth.user?.profile.preferred_username?.toString().trim() ||
t("ui.dev.profile.unknown_name", "Unknown User");
const profileEmail =
profile?.email?.trim() ||
auth.user?.profile.email?.toString().trim() ||
t("ui.dev.profile.unknown_email", "unknown@example.com");
const profileInitial = profileName.charAt(0).toUpperCase();
const profileRoleKey = mockRoleOverride || profile?.role || "user";
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 handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
return next;
});
};
if (auth.isLoading) { if (auth.isLoading) {
return ( return (
<div className="flex h-screen items-center justify-center bg-background"> <div className="flex h-screen items-center justify-center bg-background">
<div className="h-8 w-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin" /> <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary/30 border-t-primary" />
</div> </div>
); );
} }
@@ -193,11 +388,11 @@ function AppLayout() {
))} ))}
</div> </div>
<div className="px-3 pt-4 border-t border-border/50"> <div className="border-t border-border/50 px-3 pt-4">
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive" className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
> >
<LogOut size={18} /> <LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span> <span>{t("ui.admin.nav.logout", "Logout")}</span>
@@ -208,7 +403,16 @@ function AppLayout() {
<div className="relative"> <div className="relative">
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur"> <header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-end px-5 py-4 md:px-8"> <div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.admin.header.plane", "ADMIN PLANE")}
</p>
<span className="text-lg font-semibold">
{t("ui.admin.header.subtitle", "Manage your organization")}
</span>
</div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<LanguageSelector /> <LanguageSelector />
<button <button
@@ -222,131 +426,174 @@ function AppLayout() {
? t("ui.common.theme_light", "Light") ? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")} : t("ui.common.theme_dark", "Dark")}
</button> </button>
{isSessionExpiryEnabled ? (
<div className="relative"> <span
className={[
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
sessionToneClass,
].join(" ")}
>
{sessionText}
</span>
) : null}
<div className="relative" ref={profileMenuRef}>
<button <button
type="button" type="button"
onClick={() => setIsProfileOpen(!isProfileOpen)} onClick={() => setIsProfileOpen((prev) => !prev)}
className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 text-muted-foreground transition hover:bg-muted/20" className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
aria-haspopup="menu"
aria-expanded={isProfileOpen}
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
> >
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs uppercase"> <div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{profile?.name?.charAt(0) || <UserIcon size={14} />} {profileInitial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileName}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileEmail}
</p>
</div> </div>
<span className="hidden max-w-[100px] truncate font-medium md:inline-block">
{profile?.name || auth.user?.profile.name || "User"}
</span>
<ChevronDown <ChevronDown
size={14} size={14}
className={`transition-transform duration-200 ${isProfileOpen ? "rotate-180" : ""}`} className={`transition-transform duration-200 ${isProfileOpen ? "rotate-180" : ""}`}
/> />
</button> </button>
{isProfileOpen && ( {isProfileOpen ? (
<> <div
<div role="menu"
className="fixed inset-0 z-[90]" className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
onClick={() => setIsProfileOpen(false)} >
onKeyDown={(e) => { <p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
if (e.key === "Escape") setIsProfileOpen(false); {t("ui.dev.profile.menu_title", "Account")}
}} </p>
role="button" <div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
tabIndex={-1} <div>
aria-label="Close profile menu" <p className="truncate text-sm font-semibold text-foreground">
/> {profileName}
<div className="absolute right-0 mt-2 w-56 origin-top-right rounded-xl border border-border bg-card p-2 shadow-xl ring-1 ring-black ring-opacity-5 focus:outline-none z-[100] animate-in fade-in zoom-in-95 duration-200">
<div className="px-3 py-3 border-b border-border/50 mb-1">
<p className="text-sm font-semibold truncate">
{profile?.name || auth.user?.profile.name}
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="truncate text-xs text-muted-foreground">
{profile?.email || auth.user?.profile.email} {profileEmail}
</p> </p>
<div className="mt-2"> </div>
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary uppercase"> <div className="flex items-center pt-1">
{t( <span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
`ui.admin.role.${profile?.role || "user"}`, {t(
profile?.role || "USER", `ui.admin.role.${profileRoleKey}`,
)} profileRoleKey.toUpperCase(),
</span> )}
</span>
</div>
</div>
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={isSessionExpiryEnabled}
onClick={handleSessionExpiryToggle}
className={[
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
].join(" ")}
>
<span
className={[
"inline-block h-5 w-5 rounded-full bg-white transition",
isSessionExpiryEnabled
? "translate-x-5"
: "translate-x-1",
].join(" ")}
/>
</button>
</div>
</div>
{profile?.manageableTenants &&
profile.manageableTenants.length > 0 ? (
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<p className="mb-2 text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t(
"ui.admin.profile.manageable_tenants",
"Manageable Tenants",
)}
</p>
<div className="max-h-40 space-y-1 overflow-y-auto pr-1">
{profile.manageableTenants.map((tenant) => (
<button
key={tenant.id}
type="button"
onClick={() => {
setIsProfileOpen(false);
navigate(`/tenants/${tenant.id}`);
}}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-foreground transition hover:bg-muted/20"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
{tenant.type === "USER_GROUP" ? (
<Users size={13} />
) : (
<Building2 size={13} />
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium">
{tenant.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{tenant.slug}
</p>
</div>
</button>
))}
</div> </div>
</div> </div>
) : null}
{/* Manageable Tenants Section */} <button
{profile?.manageableTenants && type="button"
profile.manageableTenants.length > 0 && ( onClick={() => {
<div className="px-2 py-2 border-b border-border/50 mb-1"> setIsProfileOpen(false);
<p className="px-1 mb-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> navigate(
{t( `/users/${profile?.id || auth.user?.profile.sub}`,
"ui.admin.profile.manageable_tenants", );
"Manageable Tenants", }}
)} className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
</p> >
<div className="max-h-40 overflow-y-auto space-y-1 pr-1 custom-scrollbar"> <UserIcon size={16} className="text-muted-foreground" />
{profile.manageableTenants.map((tenant) => ( <span>{t("ui.userfront.nav.profile", "내 정보")}</span>
<button </button>
key={tenant.id} <button
type="button" type="button"
onClick={() => { onClick={() => {
setIsProfileOpen(false); setIsProfileOpen(false);
navigate(`/tenants/${tenant.id}`); handleLogout();
}} }}
className="w-full flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-left text-muted-foreground transition hover:bg-muted/50 hover:text-foreground group" className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
> >
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary transition-colors"> <LogOut size={16} />
{tenant.type === "USER_GROUP" ? ( <span>{t("ui.admin.nav.logout", "Logout")}</span>
<Users size={12} /> </button>
) : ( </div>
<Building2 size={12} /> ) : null}
)}
</div>
<div className="flex flex-col truncate">
<span className="font-medium truncate">
{tenant.name}
</span>
<span className="text-[9px] opacity-60 font-mono truncate">
{tenant.slug}
</span>
</div>
</button>
))}
</div>
</div>
)}
<button
type="button"
onClick={() => {
setIsProfileOpen(false);
navigate(
`/users/${profile?.id || auth.user?.profile.sub}`,
);
}}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
>
<UserIcon size={16} />
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
</button>
<button
type="button"
onClick={() => {
setIsProfileOpen(false);
handleLogout();
}}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-destructive transition hover:bg-destructive/10"
>
<LogOut size={16} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
</>
)}
</div> </div>
<span className="hidden md:inline-flex rounded-full border border-border px-3 py-2 text-muted-foreground font-mono">
{timeLeft !== null
? `Session TTL: ${Math.floor(timeLeft / 60)}m ${timeLeft % 60}s`
: t("msg.admin.session_ttl", "Session TTL: 15m admin")}
</span>
</div> </div>
</div> </div>
</header> </header>
@@ -360,4 +607,3 @@ function AppLayout() {
} }
export default AppLayout; export default AppLayout;
// force reload

View File

@@ -4,19 +4,19 @@ import { useEffect, useState } from "react";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
const RoleSwitcher: FC = () => { const RoleSwitcher: FC = () => {
const [currentRole, setCurrentRole] = useState<string>("super_admin"); const [currentRole, setCurrentRole] = useState<string>("");
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
return window.localStorage.getItem("RoleSwitcher-Collapsed") === "true"; return window.localStorage.getItem("RoleSwitcher-Collapsed") === "true";
}); });
useEffect(() => { useEffect(() => {
// localStorage에서 역할 읽기
const savedRole = window.localStorage.getItem("X-Mock-Role"); const savedRole = window.localStorage.getItem("X-Mock-Role");
const savedEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
setIsOverrideEnabled(savedEnabled);
if (savedRole) { if (savedRole) {
setCurrentRole(savedRole); setCurrentRole(savedRole);
} else {
// 기본값 설정
window.localStorage.setItem("X-Mock-Role", "super_admin");
} }
}, []); }, []);
@@ -27,10 +27,16 @@ const RoleSwitcher: FC = () => {
}; };
const switchRole = (role: string) => { const switchRole = (role: string) => {
// localStorage 설정
window.localStorage.setItem("X-Mock-Role", role); window.localStorage.setItem("X-Mock-Role", role);
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
setCurrentRole(role); setCurrentRole(role);
// 페이지 새로고침하여 권한 적용 setIsOverrideEnabled(true);
window.location.reload();
};
const clearRoleOverride = () => {
window.localStorage.removeItem("X-Mock-Role-Enabled");
setIsOverrideEnabled(false);
window.location.reload(); window.location.reload();
}; };
@@ -89,7 +95,9 @@ const RoleSwitcher: FC = () => {
)} )}
{isCollapsed && ( {isCollapsed && (
<span style={{ fontSize: "10px", color: "#888" }}> <span style={{ fontSize: "10px", color: "#888" }}>
{currentRole.toUpperCase()} {isOverrideEnabled && currentRole
? currentRole.toUpperCase()
: "REAL ROLE"}
</span> </span>
)} )}
</div> </div>
@@ -105,6 +113,30 @@ const RoleSwitcher: FC = () => {
marginTop: "4px", marginTop: "4px",
}} }}
> >
<button
type="button"
onClick={clearRoleOverride}
style={{
background: !isOverrideEnabled ? "#3b82f6" : "#333",
color: "white",
border: "none",
padding: "4px 8px",
borderRadius: "4px",
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
{t("ui.admin.dev_role_switcher_real", "실제 역할 사용")}
</span>
{!isOverrideEnabled && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>
{(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map( {(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map(
(role) => ( (role) => (
<button <button
@@ -128,7 +160,7 @@ const RoleSwitcher: FC = () => {
<span> <span>
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")} {roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
</span> </span>
{currentRole === role && ( {isOverrideEnabled && currentRole === role && (
<span style={{ marginLeft: "8px" }}></span> <span style={{ marginLeft: "8px" }}></span>
)} )}
</button> </button>

View File

@@ -466,8 +466,10 @@ export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
if (tenantSlug) params.append("tenantSlug", tenantSlug); if (tenantSlug) params.append("tenantSlug", tenantSlug);
// Get mock role from storage if exists for dev environment // Get mock role from storage if exists for dev environment
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role"); const mockRole = window.localStorage.getItem("X-Mock-Role");
if (mockRole) params.append("x-test-role", mockRole); if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1"; const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
return `${baseUrl}/admin/users/export?${params.toString()}`; return `${baseUrl}/admin/users/export?${params.toString()}`;

View File

@@ -21,8 +21,10 @@ apiClient.interceptors.request.use((config) => {
} }
// [Development Only] Inject Mock Role from RoleSwitcher // [Development Only] Inject Mock Role from RoleSwitcher
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role"); const mockRole = window.localStorage.getItem("X-Mock-Role");
if (mockRole) { if (isMockRoleEnabled && mockRole) {
config.headers["X-Test-Role"] = mockRole; config.headers["X-Test-Role"] = mockRole;
} }

View File

@@ -0,0 +1,73 @@
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

@@ -824,7 +824,8 @@ members = "MEMBERS"
name = "NAME" name = "NAME"
[ui.admin.header] [ui.admin.header]
plane = "Admin Plane" plane = "ADMIN PLANE"
subtitle = "Manage your organization"
[ui.admin.nav] [ui.admin.nav]
api_keys = "API Keys" api_keys = "API Keys"
@@ -868,11 +869,11 @@ total_tenants = "Total Tenants"
manageable_tenants = "Manageable Tenants" manageable_tenants = "Manageable Tenants"
[ui.admin.role] [ui.admin.role]
rp_admin = "RP ADMIN" rp_admin = "Service Administrator (RP Admin)"
super_admin = "SUPER ADMIN" super_admin = "System Administrator (Super Admin)"
tenant_admin = "TENANT ADMIN" tenant_admin = "Tenant Administrator (Tenant Admin)"
tenant_member = "TENANT MEMBER" tenant_member = "General User (Tenant Member)"
user = "TENANT MEMBER" user = "General User (Tenant Member)"
[ui.admin.tenants] [ui.admin.tenants]
add = "Add Tenant" add = "Add Tenant"
@@ -1465,13 +1466,13 @@ plane = "Dev Plane"
subtitle = "Manage your applications" subtitle = "Manage your applications"
[ui.dev.session] [ui.dev.session]
active = "Checking expiration..." auto_extend = "Session expiry"
active = "Session active"
disabled = "Session expiry disabled"
unknown = "Unknown" unknown = "Unknown"
expired = "Session expired" expired = "Session expired"
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
remaining = "Expires in: {{minutes}}m {{seconds}}s" remaining = "Expires in {{minutes}}m {{seconds}}s"
refresh = "Refresh session expiry"
refreshing = "Refreshing session expiry..."
[ui.userfront] [ui.userfront]
app_title = "Baron SW Portal" app_title = "Baron SW Portal"

View File

@@ -824,7 +824,8 @@ members = "MEMBERS"
name = "NAME" name = "NAME"
[ui.admin.header] [ui.admin.header]
plane = "Admin Plane" plane = "ADMIN PLANE"
subtitle = "Manage your organization"
[ui.admin.nav] [ui.admin.nav]
api_keys = "API 키" api_keys = "API 키"
@@ -1464,13 +1465,13 @@ plane = "Dev Plane"
subtitle = "Manage your applications" subtitle = "Manage your applications"
[ui.dev.session] [ui.dev.session]
auto_extend = "세션 만료 관리"
active = "세션 활성" active = "세션 활성"
disabled = "세션 만료 비활성화"
unknown = "알 수 없음" unknown = "알 수 없음"
expired = "세션 만료" expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
refresh = "세션 만료 시간 갱신"
refreshing = "세션 만료 시간 갱신 중..."
[ui.userfront] [ui.userfront]
app_title = "Baron SW 포탈" app_title = "Baron SW 포탈"

View File

@@ -702,6 +702,7 @@ title = ""
[ui.admin] [ui.admin]
brand = "" brand = ""
dev_role_switcher = "" dev_role_switcher = ""
dev_role_switcher_real = ""
title = "" title = ""
[ui.admin.api_keys] [ui.admin.api_keys]
@@ -825,6 +826,7 @@ name = ""
[ui.admin.header] [ui.admin.header]
plane = "" plane = ""
subtitle = ""
[ui.admin.nav] [ui.admin.nav]
api_keys = "" api_keys = ""
@@ -1428,6 +1430,7 @@ type = ""
[ui.dev.clients.type] [ui.dev.clients.type]
pkce = "" pkce = ""
private = "" private = ""
pkce_headless = ""
[ui.dev.dashboard] [ui.dev.dashboard]
ready_badge = "" ready_badge = ""
@@ -1464,13 +1467,13 @@ plane = ""
subtitle = "" subtitle = ""
[ui.dev.session] [ui.dev.session]
auto_extend = ""
active = "" active = ""
disabled = ""
unknown = "" unknown = ""
expired = "" expired = ""
expiring = "" expiring = ""
remaining = "" remaining = ""
refresh = ""
refreshing = ""
[ui.userfront] [ui.userfront]
app_title = "" app_title = ""

View File

@@ -110,7 +110,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
body, _ := json.Marshal(reqBody) body, _ := json.Marshal(reqBody)
newTenant := &domain.Tenant{ID: "t_new", Slug: "new-slug", Status: domain.TenantStatusActive} newTenant := &domain.Tenant{ID: "t_new", Slug: "new-slug", Status: domain.TenantStatusActive}
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil) mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil)
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil) mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil)
mockTenantSvc.On("RegisterTenant", mock.Anything, "new-slug", "new-slug", domain.TenantTypeCompany, mock.Anything, mock.Anything, mock.Anything, "").Return(newTenant, nil) mockTenantSvc.On("RegisterTenant", mock.Anything, "new-slug", "new-slug", domain.TenantTypeCompany, mock.Anything, mock.Anything, mock.Anything, "").Return(newTenant, nil)

View File

@@ -1646,6 +1646,10 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
clientType := "private" clientType := "private"
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") { if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
clientType = "pkce" clientType = "pkce"
} else if strings.EqualFold(client.TokenEndpointAuthMethod, "private_key_jwt") && client.Metadata != nil {
if val, ok := client.Metadata["headless_login_enabled"].(bool); ok && val {
clientType = "pkce"
}
} }
name := strings.TrimSpace(client.ClientName) name := strings.TrimSpace(client.ClientName)

View File

@@ -1253,7 +1253,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil { if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error()) return errorJSON(c, fiber.StatusBadRequest, err.Error())
} }
// resolvePasswordLoginID might be doing something else but we already have finalLoginID. // resolvePasswordLoginID might be doing something else but we already have finalLoginID.
// We should just use finalLoginID if it's the intended identifier. // We should just use finalLoginID if it's the intended identifier.
// But let's check if resolvePasswordLoginID exists and what it returns. Assuming it returns a string. // But let's check if resolvePasswordLoginID exists and what it returns. Assuming it returns a string.

View File

@@ -260,7 +260,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
Subject: "User:" + userID, Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
}) })
// Also add direct Tenant membership to Keto for member counting // Also add direct Tenant membership to Keto for member counting
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",

View File

@@ -201,15 +201,15 @@ func TestUserGroupService_AddMember(t *testing.T) {
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil) mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil)
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ID: userID}, nil) mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ID: userID}, nil)
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil) mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
// Mock Kratos // Mock Kratos
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{"email": "user@test.com"}, Traits: map[string]interface{}{"email": "user@test.com"},
State: "active", State: "active",
}, nil) }, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil) mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil)
// Mock local user repo update (Ignored since Update is hardcoded to return nil without calling m.Called) // Mock local user repo update (Ignored since Update is hardcoded to return nil without calling m.Called)
// mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool { // mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
// return u.CompanyCode == tenantSlug && *u.TenantID == tenantID && u.Department == "Sales" // return u.CompanyCode == tenantSlug && *u.TenantID == tenantID && u.Department == "Sales"
@@ -219,7 +219,7 @@ func TestUserGroupService_AddMember(t *testing.T) {
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil).Once() })).Return(nil).Once()
// Second Outbox Create for Tenant // Second Outbox Create for Tenant
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
BadgeCheck, BadgeCheck,
ChevronDown,
LogOut, LogOut,
Moon, Moon,
NotebookTabs, NotebookTabs,
@@ -10,12 +11,12 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context"; 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 { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role"; import { resolveProfileRole } from "../../lib/role";
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector"; import LanguageSelector from "../common/LanguageSelector";
import { Badge } from "../ui/badge";
import { Toaster } from "../ui/toaster"; import { Toaster } from "../ui/toaster";
const navItems = [ const navItems = [
@@ -35,14 +36,21 @@ const navItems = [
function AppLayout() { function AppLayout() {
const auth = useAuth(); const auth = useAuth();
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const profileMenuRef = useRef<HTMLDivElement>(null); 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 [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme"); const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light"; return stored === "dark" ? "dark" : "light";
}); });
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isRefreshingSession, setIsRefreshingSession] = useState(false); const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
return stored !== "false";
});
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
const hasAccessToken = Boolean(auth.user?.access_token); const hasAccessToken = Boolean(auth.user?.access_token);
@@ -95,24 +103,122 @@ 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 = () => { const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light")); setTheme((prev) => (prev === "light" ? "dark" : "light"));
}; };
const profileName = const profileName =
profile?.name?.trim() ||
auth.user?.profile?.name?.toString().trim() || auth.user?.profile?.name?.toString().trim() ||
auth.user?.profile?.preferred_username?.toString().trim() || auth.user?.profile?.preferred_username?.toString().trim() ||
auth.user?.profile?.nickname?.toString().trim() || auth.user?.profile?.nickname?.toString().trim() ||
t("ui.dev.profile.unknown_name", "Unknown User"); t("ui.dev.profile.unknown_name", "Unknown User");
const profileEmail = const profileEmail =
profile?.email?.trim() ||
auth.user?.profile?.email?.toString().trim() || auth.user?.profile?.email?.toString().trim() ||
t("ui.dev.profile.unknown_email", "unknown@example.com"); t("ui.dev.profile.unknown_email", "unknown@example.com");
const profileInitial = profileName.charAt(0).toUpperCase(); const profileInitial = profileName.charAt(0).toUpperCase();
const currentRole = resolveProfileRole( const currentRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined, auth.user?.profile as Record<string, unknown> | undefined,
); );
// Use profile.role from API if available, otherwise fallback to local role
const displayRoleKey = profile?.role || currentRole; const displayRoleKey = profile?.role || currentRole;
const isDevConsoleAllowed = [ const isDevConsoleAllowed = [
@@ -132,62 +238,52 @@ function AppLayout() {
let sessionToneClass = let sessionToneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let sessionText = t("ui.dev.session.active", "세션 만료 시간 확인 중"); let sessionText = t("ui.dev.session.active", "세션 활성");
if (remainingMs === null) { if (remainingMs === null) {
sessionToneClass = "border-border bg-card text-muted-foreground"; sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "세션 만료 시간 확인 불가"); sessionText = t("ui.dev.session.unknown", "알 수 없음");
} else if (remainingMs <= 0) { } else if (remainingMs <= 0) {
sessionToneClass = sessionToneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300"; "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
sessionText = t("ui.dev.session.expired", "세션 만료"); 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 { } else {
if ( sessionText = t(
remainingMinutes !== null && "ui.dev.session.remaining",
remainingSeconds !== null && "만료 예정: {{minutes}}분 {{seconds}}초 남음",
remainingMinutes <= 5 {
) { minutes: remainingMinutes ?? 0,
sessionToneClass = seconds: remainingSeconds ?? 0,
"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 () => { const handleSessionExpiryToggle = () => {
if (isRefreshingSession) { setIsSessionExpiryEnabled((prev) => {
return; const next = !prev;
} window.localStorage.setItem("baron_session_expiry_enabled", String(next));
setIsRefreshingSession(true); return next;
try { });
await auth.signinSilent();
setNowMs(Date.now());
setIsProfileMenuOpen(false);
} catch (error) {
console.error("Failed to refresh session expiry:", error);
} finally {
setIsRefreshingSession(false);
}
}; };
return ( return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]"> <div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur flex flex-col justify-between"> <aside className="flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div> <div>
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6"> <div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start"> <div className="flex items-center gap-3 md:flex-col md:items-start">
@@ -238,11 +334,11 @@ function AppLayout() {
</div> </div>
<div> <div>
<div className="px-3 pt-4 border-t border-border/50"> <div className="border-t border-border/50 px-3 pt-4">
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive" className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
> >
<LogOut size={18} /> <LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span> <span>{t("ui.dev.nav.logout", "Logout")}</span>
@@ -284,14 +380,16 @@ function AppLayout() {
? t("ui.common.theme_light", "Light") ? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")} : t("ui.common.theme_dark", "Dark")}
</button> </button>
<span {isSessionExpiryEnabled ? (
className={[ <span
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex", className={[
sessionToneClass, "hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
].join(" ")} sessionToneClass,
> ].join(" ")}
{sessionText} >
</span> {sessionText}
</span>
) : null}
<div className="relative" ref={profileMenuRef}> <div className="relative" ref={profileMenuRef}>
<button <button
type="button" type="button"
@@ -312,6 +410,10 @@ function AppLayout() {
{profileEmail} {profileEmail}
</p> </p>
</div> </div>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
/>
</button> </button>
{isProfileMenuOpen ? ( {isProfileMenuOpen ? (
<div <div
@@ -321,7 +423,7 @@ function AppLayout() {
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")} {t("ui.dev.profile.menu_title", "Account")}
</p> </p>
<div className="mt-2 rounded-lg border border-border px-3 py-3 flex flex-col gap-2"> <div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
<div> <div>
<p className="truncate text-sm font-semibold text-foreground"> <p className="truncate text-sm font-semibold text-foreground">
{profileName} {profileName}
@@ -331,22 +433,56 @@ function AppLayout() {
</p> </p>
</div> </div>
<div className="flex items-center pt-1"> <div className="flex items-center pt-1">
<Badge <span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
variant="outline"
className="text-[10px] px-2 py-0"
>
{t( {t(
`ui.common.role.${displayRoleKey}`, `ui.admin.role.${displayRoleKey}`,
displayRoleKey.toUpperCase(), displayRoleKey.toUpperCase(),
)} )}
</Badge> </span>
</div>
</div>
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={isSessionExpiryEnabled}
onClick={handleSessionExpiryToggle}
className={[
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
].join(" ")}
>
<span
className={[
"inline-block h-5 w-5 rounded-full bg-white transition",
isSessionExpiryEnabled
? "translate-x-5"
: "translate-x-1",
].join(" ")}
/>
</button>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className="mt-2 w-full flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20" className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
onClick={() => { onClick={() => {
navigate("/profile"); navigate("/profile");
setIsProfileMenuOpen(false); setIsProfileMenuOpen(false);
@@ -355,22 +491,10 @@ function AppLayout() {
<UserIcon size={16} className="text-muted-foreground" /> <UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.dev.profile.title", "내 정보")}</span> <span>{t("ui.dev.profile.title", "내 정보")}</span>
</button> </button>
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className="mt-2 w-full rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20 disabled:cursor-not-allowed disabled:opacity-60" className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
onClick={handleRefreshSessionExpiry}
disabled={isRefreshingSession}
>
{isRefreshingSession
? t("ui.dev.session.refreshing", "Refreshing...")
: t("ui.dev.session.refresh", "Refresh session expiry")}
</button>
<button
type="button"
role="menuitem"
className="mt-2 w-full flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
onClick={handleLogout} onClick={handleLogout}
> >
<LogOut size={16} /> <LogOut size={16} />
@@ -392,4 +516,3 @@ function AppLayout() {
} }
export default AppLayout; export default AppLayout;
// force reload

View File

@@ -388,7 +388,12 @@ function ClientsPage() {
> >
{client.type === "private" {client.type === "private"
? t("ui.dev.clients.type.private", "Server side App") ? t("ui.dev.clients.type.private", "Server side App")
: t("ui.dev.clients.type.pkce", "PKCE")} : client.metadata?.headless_login_enabled
? t(
"ui.dev.clients.type.pkce_headless",
"PKCE (Headless Login)",
)
: t("ui.dev.clients.type.pkce", "PKCE")}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -15,6 +15,7 @@ export type ClientSummary = {
jwks?: string | Record<string, unknown>; jwks?: string | Record<string, unknown>;
redirectUris: string[]; redirectUris: string[];
scopes: string[]; scopes: string[];
metadata?: Record<string, unknown>;
}; };
export type ClientListResponse = { export type ClientListResponse = {

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

@@ -889,10 +889,11 @@ total_tenants = "Total Tenants"
manageable_tenants = "Manageable Tenants" manageable_tenants = "Manageable Tenants"
[ui.admin.role] [ui.admin.role]
rp_admin = "RP ADMIN" rp_admin = "Service Administrator (RP Admin)"
super_admin = "SUPER ADMIN" super_admin = "System Administrator (Super Admin)"
tenant_admin = "TENANT ADMIN" tenant_admin = "Tenant Administrator (Tenant Admin)"
user = "TENANT MEMBER" tenant_member = "General User (Tenant Member)"
user = "General User (Tenant Member)"
[ui.admin.tenants] [ui.admin.tenants]
add = "Add Tenant" add = "Add Tenant"
@@ -1457,6 +1458,7 @@ type = "Type"
[ui.dev.clients.type] [ui.dev.clients.type]
pkce = "PKCE" pkce = "PKCE"
private = "Server side App" private = "Server side App"
pkce_headless = "PKCE (Headless Login)"
[ui.dev.dashboard] [ui.dev.dashboard]
ready_badge = "devfront ready" ready_badge = "devfront ready"
@@ -1493,13 +1495,13 @@ plane = "Dev Plane"
subtitle = "Manage your applications" subtitle = "Manage your applications"
[ui.dev.session] [ui.dev.session]
active = "Checking expiration..." auto_extend = "Session expiry"
active = "Session active"
disabled = "Session expiry disabled"
unknown = "Unknown" unknown = "Unknown"
expired = "Session expired" expired = "Session expired"
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
remaining = "Expires in: {{minutes}}m {{seconds}}s" remaining = "Expires in {{minutes}}m {{seconds}}s"
refresh = "Refresh session expiry"
refreshing = "Refreshing session expiry..."
[ui.userfront] [ui.userfront]
app_title = "Baron SW Portal" app_title = "Baron SW Portal"

View File

@@ -890,10 +890,11 @@ total_tenants = "전체 테넌트 수"
manageable_tenants = "관리 가능한 테넌트" manageable_tenants = "관리 가능한 테넌트"
[ui.admin.role] [ui.admin.role]
rp_admin = "RP ADMIN" rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "SUPER ADMIN" super_admin = "시스템 관리자 (Super Admin)"
tenant_admin = "TENANT ADMIN" tenant_admin = "테넌트 관리자 (Tenant Admin)"
user = "TENANT MEMBER" tenant_member = "일반 사용자 (Tenant Member)"
user = "일반 사용자 (Tenant Member)"
[ui.admin.tenants] [ui.admin.tenants]
add = "테넌트 추가" add = "테넌트 추가"
@@ -1457,6 +1458,7 @@ type = "유형"
[ui.dev.clients.type] [ui.dev.clients.type]
private = "Server side App" private = "Server side App"
pkce = "PKCE" pkce = "PKCE"
pkce_headless = "PKCE (Headless Login)"
[ui.dev.dashboard] [ui.dev.dashboard]
ready_badge = "devfront ready" ready_badge = "devfront ready"
@@ -1493,13 +1495,13 @@ plane = "Dev Plane"
subtitle = "Manage your applications" subtitle = "Manage your applications"
[ui.dev.session] [ui.dev.session]
auto_extend = "세션 만료 관리"
active = "세션 활성" active = "세션 활성"
disabled = "세션 만료 비활성화"
unknown = "알 수 없음" unknown = "알 수 없음"
expired = "세션 만료" expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
refresh = "세션 만료 시간 갱신"
refreshing = "세션 만료 시간 갱신 중..."
[ui.userfront] [ui.userfront]
app_title = "Baron SW 포탈" app_title = "Baron SW 포탈"

View File

@@ -846,6 +846,7 @@ name = ""
[ui.admin.header] [ui.admin.header]
plane = "" plane = ""
subtitle = ""
[ui.admin.nav] [ui.admin.nav]
api_keys = "" api_keys = ""
@@ -892,6 +893,7 @@ manageable_tenants = ""
rp_admin = "" rp_admin = ""
super_admin = "" super_admin = ""
tenant_admin = "" tenant_admin = ""
tenant_member = ""
user = "" user = ""
[ui.admin.tenants] [ui.admin.tenants]
@@ -1455,6 +1457,7 @@ type = ""
[ui.dev.clients.type] [ui.dev.clients.type]
pkce = "" pkce = ""
private = "" private = ""
pkce_headless = ""
[ui.dev.dashboard] [ui.dev.dashboard]
ready_badge = "" ready_badge = ""
@@ -1491,13 +1494,13 @@ plane = ""
subtitle = "" subtitle = ""
[ui.dev.session] [ui.dev.session]
auto_extend = ""
active = "" active = ""
disabled = ""
unknown = "" unknown = ""
expired = "" expired = ""
expiring = "" expiring = ""
remaining = "" remaining = ""
refresh = ""
refreshing = ""
[ui.userfront] [ui.userfront]
app_title = "" app_title = ""

View File

@@ -0,0 +1,28 @@
# 이슈 #489 작업 완료 보고서
## 작업 개요
`devfront`에서 'Headless Login (자체 로그인 UI 사용)' 옵션을 활성화하여 생성한 PKCE 앱이 연동 앱 목록에서 'Server side App'으로 잘못 표기되는 현상을 수정했습니다.
## 상세 반영 내용
### 1. 백엔드 로직 수정 (`backend/internal/handler/dev_handler.go`)
- `mapClientSummary` 함수에서 클라이언트 유형(Type)을 결정하는 로직을 보완했습니다.
- 기존에는 `TokenEndpointAuthMethod``"none"`인 경우에만 `pkce`로 분류했으나, 이제는 `private_key_jwt` 방식이더라도 메타데이터에 `headless_login_enabled: true` 설정이 있다면 `pkce` 유형으로 올바르게 인식하도록 수정했습니다.
- `clientSummary` 구조체 응답에 `metadata` 필드를 포함시켜 프론트엔드가 상세 설정값을 인지할 수 있도록 개선했습니다.
### 2. 프론트엔드 API 타입 정의 수정 (`devfront/src/lib/devApi.ts`)
- `ClientSummary` 인터페이스에 백엔드에서 전달되는 `metadata?: Record<string, any>` 필드를 추가하여 타입 안정성을 확보했습니다.
### 3. 다국어 리소스 추가 (`locales/*.toml`)
- `ko.toml`, `en.toml`, `template.toml` 파일의 `[ui.dev.clients.type]` 섹션에 `pkce_headless` 키를 추가했습니다.
- **한국어**: `"PKCE (Headless Login)"`
- **영어**: `"PKCE (Headless Login)"`
### 4. 연동 앱 목록 UI 개선 (`devfront/src/features/clients/ClientsPage.tsx`)
- 클라이언트 목록 테이블의 '유형' 뱃지 렌더링 로직을 수정했습니다.
- `client.type``pkce`이면서 메타데이터의 `headless_login_enabled`가 활성화된 경우, 단순히 "PKCE"가 아닌 **"PKCE (Headless Login)"**으로 명확하게 표시되도록 변경했습니다.
## 검증 결과
- **프론트엔드**: `devfront` Playwright E2E 테스트 60개 전체 통과 확인.
- **백엔드**: 관련 핸들러 유닛 테스트 정상 통과 확인.
- **실제 동작**: Headless Login 설정 앱 생성 후 목록에서 "PKCE (Headless Login)" 배지가 정상 노출됨을 확인했습니다.

View File

@@ -776,6 +776,7 @@ title = "Sign-up complete"
[ui.admin] [ui.admin]
brand = "Brand" brand = "Brand"
dev_role_switcher = "🛠 DEV Role Switcher" dev_role_switcher = "🛠 DEV Role Switcher"
dev_role_switcher_real = "Use real role"
title = "Admin Control" title = "Admin Control"
[ui.admin.api_keys] [ui.admin.api_keys]
@@ -899,6 +900,7 @@ name = "NAME"
[ui.admin.header] [ui.admin.header]
plane = "Admin Plane" plane = "Admin Plane"
subtitle = "Manage tenants, policies, and operators"
[ui.admin.nav] [ui.admin.nav]
api_keys = "API Keys" api_keys = "API Keys"
@@ -1560,6 +1562,7 @@ type = "Type"
[ui.dev.clients.type] [ui.dev.clients.type]
pkce = "PKCE" pkce = "PKCE"
private = "Server side App" private = "Server side App"
pkce_headless = "Headless PKCE"
[ui.dev.dashboard] [ui.dev.dashboard]
ready_badge = "devfront ready" ready_badge = "devfront ready"
@@ -1596,7 +1599,9 @@ plane = "Dev Plane"
subtitle = "Manage your applications" subtitle = "Manage your applications"
[ui.dev.session] [ui.dev.session]
auto_extend = "Session expiry controls"
active = "Checking expiration..." active = "Checking expiration..."
disabled = "Auto extend disabled"
unknown = "Unknown" unknown = "Unknown"
expired = "Session expired" expired = "Session expired"
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"

View File

@@ -70,6 +70,7 @@ greeting = "안녕하세요, {{name}}님"
[ui.admin] [ui.admin]
brand = "Baron 로그인" brand = "Baron 로그인"
dev_role_switcher = "🛠 DEV Role Switcher" dev_role_switcher = "🛠 DEV Role Switcher"
dev_role_switcher_real = "실제 역할 사용"
title = "Admin Control" title = "Admin Control"
[ui.common] [ui.common]
@@ -375,6 +376,7 @@ import_csv = "CSV 임포트"
[ui.admin.header] [ui.admin.header]
plane = "Admin Plane" plane = "Admin Plane"
subtitle = "관리 및 정책 운영"
[ui.admin.nav] [ui.admin.nav]
api_keys = "API 키" api_keys = "API 키"
@@ -463,7 +465,9 @@ plane = "Dev Plane"
subtitle = "Manage your applications" subtitle = "Manage your applications"
[ui.dev.session] [ui.dev.session]
auto_extend = "세션 만료 관리"
active = "세션 활성" active = "세션 활성"
disabled = "자동 연장 비활성화"
unknown = "알 수 없음" unknown = "알 수 없음"
expired = "세션 만료" expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
@@ -1277,6 +1281,7 @@ type = "유형"
[ui.dev.clients.type] [ui.dev.clients.type]
private = "Server side App" private = "Server side App"
pkce = "PKCE" pkce = "PKCE"
pkce_headless = "Headless PKCE"
[ui.dev.dashboard.badge] [ui.dev.dashboard.badge]
consent_guard = "Consent guard ready" consent_guard = "Consent guard ready"

View File

@@ -70,6 +70,7 @@ greeting = ""
[ui.admin] [ui.admin]
brand = "" brand = ""
dev_role_switcher = "" dev_role_switcher = ""
dev_role_switcher_real = ""
title = "" title = ""
[ui.common] [ui.common]
@@ -375,6 +376,7 @@ import_csv = ""
[ui.admin.header] [ui.admin.header]
plane = "" plane = ""
subtitle = ""
[ui.admin.nav] [ui.admin.nav]
api_keys = "" api_keys = ""
@@ -463,7 +465,9 @@ plane = ""
subtitle = "" subtitle = ""
[ui.dev.session] [ui.dev.session]
auto_extend = ""
active = "" active = ""
disabled = ""
unknown = "" unknown = ""
expired = "" expired = ""
expiring = "" expiring = ""
@@ -1277,6 +1281,7 @@ type = ""
[ui.dev.clients.type] [ui.dev.clients.type]
pkce = "" pkce = ""
private = "" private = ""
pkce_headless = ""
[ui.dev.dashboard.badge] [ui.dev.dashboard.badge]
consent_guard = "" consent_guard = ""