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",
"format": "biome format . --write",
"preview": "vite preview",
"test": "playwright test",
"test": "npx playwright test",
"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"
},
"dependencies": {

View File

@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
Building2,
ChevronDown,
Key,
@@ -15,11 +14,12 @@ import {
Users,
} from "lucide-react";
import * as React from "react";
import { useEffect, useState } 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";
@@ -33,24 +33,41 @@ 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 })
._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 stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [timeLeft, setTimeLeft] = useState<number | null>(null);
const expiresAt = auth.user?.expires_at;
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
return stored !== "false";
});
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (!expiresAt) return;
const updateTimer = () => {
setTimeLeft(Math.max(0, Math.floor(expiresAt - Date.now() / 1000)));
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
}, []);
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -67,10 +84,10 @@ function AppLayout() {
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
const effectiveRole = mockRoleOverride || profile?.role;
// 테스트 모드이면 profile이 없어도 super_admin으로 간주하여 모든 메뉴 렌더링
const isSuperAdmin = isTest || profile?.role === "super_admin";
const isTenantAdmin = profile?.role === "tenant_admin";
const isSuperAdmin = isTest || effectiveRole === "super_admin";
const isTenantAdmin = effectiveRole === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0;
const filteredItems = items.filter((item) => {
@@ -87,14 +104,12 @@ function AppLayout() {
});
} else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) {
// Direct link if only one (or zero in array but has tenantId) tenant
filteredItems.splice(1, 0, {
label: "ui.admin.nav.my_tenant",
to: `/tenants/${profile.tenantId}`,
icon: Building2,
});
} else if (manageableCount > 1) {
// Show list menu if multiple tenants
filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants",
to: "/tenants",
@@ -104,7 +119,7 @@ function AppLayout() {
}
return filteredItems;
}, [profile]);
}, [mockRoleOverride, profile]);
const handleLogout = () => {
if (
@@ -142,14 +157,194 @@ function AppLayout() {
window.localStorage.setItem("admin_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 = () => {
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) {
return (
<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>
);
}
@@ -193,11 +388,11 @@ function AppLayout() {
))}
</div>
<div className="px-3 pt-4 border-t border-border/50">
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
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} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
@@ -208,7 +403,16 @@ function AppLayout() {
<div className="relative">
<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">
<LanguageSelector />
<button
@@ -222,131 +426,174 @@ function AppLayout() {
? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")}
</button>
<div className="relative">
{isSessionExpiryEnabled ? (
<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
type="button"
onClick={() => setIsProfileOpen(!isProfileOpen)}
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"
onClick={() => setIsProfileOpen((prev) => !prev)}
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">
{profile?.name?.charAt(0) || <UserIcon size={14} />}
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{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>
<span className="hidden max-w-[100px] truncate font-medium md:inline-block">
{profile?.name || auth.user?.profile.name || "User"}
</span>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isProfileOpen ? "rotate-180" : ""}`}
/>
</button>
{isProfileOpen && (
<>
<div
className="fixed inset-0 z-[90]"
onClick={() => setIsProfileOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setIsProfileOpen(false);
}}
role="button"
tabIndex={-1}
aria-label="Close profile menu"
/>
<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}
{isProfileOpen ? (
<div
role="menu"
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
</p>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileName}
</p>
<p className="text-xs text-muted-foreground truncate">
{profile?.email || auth.user?.profile.email}
<p className="truncate text-xs text-muted-foreground">
{profileEmail}
</p>
<div className="mt-2">
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary uppercase">
{t(
`ui.admin.role.${profile?.role || "user"}`,
profile?.role || "USER",
)}
</span>
</div>
<div className="flex items-center pt-1">
<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">
{t(
`ui.admin.role.${profileRoleKey}`,
profileRoleKey.toUpperCase(),
)}
</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>
) : null}
{/* Manageable Tenants Section */}
{profile?.manageableTenants &&
profile.manageableTenants.length > 0 && (
<div className="px-2 py-2 border-b border-border/50 mb-1">
<p className="px-1 mb-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{t(
"ui.admin.profile.manageable_tenants",
"Manageable Tenants",
)}
</p>
<div className="max-h-40 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
{profile.manageableTenants.map((tenant) => (
<button
key={tenant.id}
type="button"
onClick={() => {
setIsProfileOpen(false);
navigate(`/tenants/${tenant.id}`);
}}
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"
>
<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">
{tenant.type === "USER_GROUP" ? (
<Users size={12} />
) : (
<Building2 size={12} />
)}
</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>
</>
)}
<button
type="button"
onClick={() => {
setIsProfileOpen(false);
navigate(
`/users/${profile?.id || auth.user?.profile.sub}`,
);
}}
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"
>
<UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
</button>
<button
type="button"
onClick={() => {
setIsProfileOpen(false);
handleLogout();
}}
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"
>
<LogOut size={16} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
) : null}
</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>
</header>
@@ -360,4 +607,3 @@ function AppLayout() {
}
export default AppLayout;
// force reload

View File

@@ -4,19 +4,19 @@ import { useEffect, useState } from "react";
import { t } from "../../lib/i18n";
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>(() => {
return window.localStorage.getItem("RoleSwitcher-Collapsed") === "true";
});
useEffect(() => {
// localStorage에서 역할 읽기
const savedRole = window.localStorage.getItem("X-Mock-Role");
const savedEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
setIsOverrideEnabled(savedEnabled);
if (savedRole) {
setCurrentRole(savedRole);
} else {
// 기본값 설정
window.localStorage.setItem("X-Mock-Role", "super_admin");
}
}, []);
@@ -27,10 +27,16 @@ const RoleSwitcher: FC = () => {
};
const switchRole = (role: string) => {
// localStorage 설정
window.localStorage.setItem("X-Mock-Role", role);
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
setCurrentRole(role);
// 페이지 새로고침하여 권한 적용
setIsOverrideEnabled(true);
window.location.reload();
};
const clearRoleOverride = () => {
window.localStorage.removeItem("X-Mock-Role-Enabled");
setIsOverrideEnabled(false);
window.location.reload();
};
@@ -89,7 +95,9 @@ const RoleSwitcher: FC = () => {
)}
{isCollapsed && (
<span style={{ fontSize: "10px", color: "#888" }}>
{currentRole.toUpperCase()}
{isOverrideEnabled && currentRole
? currentRole.toUpperCase()
: "REAL ROLE"}
</span>
)}
</div>
@@ -105,6 +113,30 @@ const RoleSwitcher: FC = () => {
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(
(role) => (
<button
@@ -128,7 +160,7 @@ const RoleSwitcher: FC = () => {
<span>
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
</span>
{currentRole === role && (
{isOverrideEnabled && currentRole === role && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>

View File

@@ -466,8 +466,10 @@ export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
if (tenantSlug) params.append("tenantSlug", tenantSlug);
// 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");
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";
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
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (mockRole) {
if (isMockRoleEnabled && 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"
[ui.admin.header]
plane = "Admin Plane"
plane = "ADMIN PLANE"
subtitle = "Manage your organization"
[ui.admin.nav]
api_keys = "API Keys"
@@ -868,11 +869,11 @@ total_tenants = "Total Tenants"
manageable_tenants = "Manageable Tenants"
[ui.admin.role]
rp_admin = "RP ADMIN"
super_admin = "SUPER ADMIN"
tenant_admin = "TENANT ADMIN"
tenant_member = "TENANT MEMBER"
user = "TENANT MEMBER"
rp_admin = "Service Administrator (RP Admin)"
super_admin = "System Administrator (Super Admin)"
tenant_admin = "Tenant Administrator (Tenant Admin)"
tenant_member = "General User (Tenant Member)"
user = "General User (Tenant Member)"
[ui.admin.tenants]
add = "Add Tenant"
@@ -1465,13 +1466,13 @@ plane = "Dev Plane"
subtitle = "Manage your applications"
[ui.dev.session]
active = "Checking expiration..."
auto_extend = "Session expiry"
active = "Session active"
disabled = "Session expiry disabled"
unknown = "Unknown"
expired = "Session expired"
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
remaining = "Expires in: {{minutes}}m {{seconds}}s"
refresh = "Refresh session expiry"
refreshing = "Refreshing session expiry..."
remaining = "Expires in {{minutes}}m {{seconds}}s"
[ui.userfront]
app_title = "Baron SW Portal"

View File

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

View File

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

View File

@@ -110,7 +110,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
body, _ := json.Marshal(reqBody)
newTenant := &domain.Tenant{ID: "t_new", Slug: "new-slug", Status: domain.TenantStatusActive}
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").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)

View File

@@ -1646,6 +1646,10 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
clientType := "private"
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
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)

View File

@@ -1253,7 +1253,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
// resolvePasswordLoginID might be doing something else but we already have finalLoginID.
// 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.

View File

@@ -260,7 +260,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
// Also add direct Tenant membership to Keto for member counting
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
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)
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)
// Mock Kratos
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
ID: userID,
ID: userID,
Traits: map[string]interface{}{"email": "user@test.com"},
State: "active",
State: "active",
}, 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)
// mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
// 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 {
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil).Once()
// Second Outbox Create for Tenant
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

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
ChevronDown,
LogOut,
Moon,
NotebookTabs,
@@ -10,12 +11,12 @@ 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 { Badge } from "../ui/badge";
import { Toaster } from "../ui/toaster";
const navItems = [
@@ -35,14 +36,21 @@ 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";
});
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 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 = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const profileName =
profile?.name?.trim() ||
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 =
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 currentRole = resolveProfileRole(
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 isDevConsoleAllowed = [
@@ -132,62 +238,52 @@ function AppLayout() {
let sessionToneClass =
"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) {
sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "세션 만료 시간 확인 불가");
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", "세션 만료");
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 {
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,
},
);
}
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);
}
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
return next;
});
};
return (
<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 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">
@@ -238,11 +334,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
type="button"
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} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
@@ -284,14 +380,16 @@ function AppLayout() {
? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")}
</button>
<span
className={[
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
sessionToneClass,
].join(" ")}
>
{sessionText}
</span>
{isSessionExpiryEnabled ? (
<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
type="button"
@@ -312,6 +410,10 @@ function AppLayout() {
{profileEmail}
</p>
</div>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
/>
</button>
{isProfileMenuOpen ? (
<div
@@ -321,7 +423,7 @@ function AppLayout() {
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
</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>
<p className="truncate text-sm font-semibold text-foreground">
{profileName}
@@ -331,22 +433,56 @@ function AppLayout() {
</p>
</div>
<div className="flex items-center pt-1">
<Badge
variant="outline"
className="text-[10px] px-2 py-0"
>
<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">
{t(
`ui.common.role.${displayRoleKey}`,
`ui.admin.role.${displayRoleKey}`,
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>
<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-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={() => {
navigate("/profile");
setIsProfileMenuOpen(false);
@@ -355,22 +491,10 @@ function AppLayout() {
<UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.dev.profile.title", "내 정보")}</span>
</button>
<button
type="button"
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"
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"
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={handleLogout}
>
<LogOut size={16} />
@@ -392,4 +516,3 @@ function AppLayout() {
}
export default AppLayout;
// force reload

View File

@@ -388,7 +388,12 @@ function ClientsPage() {
>
{client.type === "private"
? 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>
</TableCell>
<TableCell>

View File

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

View File

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

View File

@@ -846,6 +846,7 @@ name = ""
[ui.admin.header]
plane = ""
subtitle = ""
[ui.admin.nav]
api_keys = ""
@@ -892,6 +893,7 @@ manageable_tenants = ""
rp_admin = ""
super_admin = ""
tenant_admin = ""
tenant_member = ""
user = ""
[ui.admin.tenants]
@@ -1455,6 +1457,7 @@ type = ""
[ui.dev.clients.type]
pkce = ""
private = ""
pkce_headless = ""
[ui.dev.dashboard]
ready_badge = ""
@@ -1491,13 +1494,13 @@ plane = ""
subtitle = ""
[ui.dev.session]
auto_extend = ""
active = ""
disabled = ""
unknown = ""
expired = ""
expiring = ""
remaining = ""
refresh = ""
refreshing = ""
[ui.userfront]
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]
brand = "Brand"
dev_role_switcher = "🛠 DEV Role Switcher"
dev_role_switcher_real = "Use real role"
title = "Admin Control"
[ui.admin.api_keys]
@@ -899,6 +900,7 @@ name = "NAME"
[ui.admin.header]
plane = "Admin Plane"
subtitle = "Manage tenants, policies, and operators"
[ui.admin.nav]
api_keys = "API Keys"
@@ -1560,6 +1562,7 @@ type = "Type"
[ui.dev.clients.type]
pkce = "PKCE"
private = "Server side App"
pkce_headless = "Headless PKCE"
[ui.dev.dashboard]
ready_badge = "devfront ready"
@@ -1596,7 +1599,9 @@ plane = "Dev Plane"
subtitle = "Manage your applications"
[ui.dev.session]
auto_extend = "Session expiry controls"
active = "Checking expiration..."
disabled = "Auto extend disabled"
unknown = "Unknown"
expired = "Session expired"
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"

View File

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

View File

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