1
0
forked from baron/baron-sso

adminfront/devfront 상단바 프로필 메뉴 UI 통일

This commit is contained in:
2026-04-01 11:55:06 +09:00
parent 8d505cec0e
commit 32a0efbf1b
11 changed files with 459 additions and 252 deletions

View File

@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
Building2,
ChevronDown,
Key,
@@ -15,7 +14,7 @@ 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 { fetchMe } from "../../lib/adminApi";
@@ -34,23 +33,37 @@ const staticNavItems = [
function AppLayout() {
const auth = useAuth();
const navigate = useNavigate();
const profileMenuRef = useRef<HTMLDivElement>(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 +80,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 +100,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 +115,7 @@ function AppLayout() {
}
return filteredItems;
}, [profile]);
}, [mockRoleOverride, profile]);
const handleLogout = () => {
if (
@@ -142,14 +153,99 @@ 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);
};
}, []);
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 +289,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 +304,19 @@ 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 +330,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.expired", "세션 만료")}
</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 +511,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,26 @@ 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 +156,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

@@ -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,12 @@ plane = "Dev Plane"
subtitle = "Manage your applications"
[ui.dev.session]
active = "Checking expiration..."
active = "Session active"
disabled = "Session expiry display 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 키"
@@ -1465,12 +1466,11 @@ subtitle = "Manage your applications"
[ui.dev.session]
active = "세션 활성"
disabled = "세션 만료 표시 비활성화"
unknown = "알 수 없음"
expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
refresh = "세션 만료 시간 갱신"
refreshing = "세션 만료 시간 갱신 중..."
[ui.userfront]
app_title = "Baron SW 포탈"

View File

@@ -825,6 +825,7 @@ name = ""
[ui.admin.header]
plane = ""
subtitle = ""
[ui.admin.nav]
api_keys = ""
@@ -1465,12 +1466,11 @@ subtitle = ""
[ui.dev.session]
active = ""
disabled = ""
unknown = ""
expired = ""
expiring = ""
remaining = ""
refresh = ""
refreshing = ""
[ui.userfront]
app_title = ""

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
ChevronDown,
LogOut,
Moon,
NotebookTabs,
@@ -15,7 +16,6 @@ import { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import LanguageSelector from "../common/LanguageSelector";
import { Badge } from "../ui/badge";
import { Toaster } from "../ui/toaster";
const navItems = [
@@ -42,7 +42,10 @@ function AppLayout() {
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);
@@ -100,19 +103,19 @@ function AppLayout() {
};
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 +135,55 @@ 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 +234,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 +280,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 +310,10 @@ function AppLayout() {
{profileEmail}
</p>
</div>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
/>
</button>
{isProfileMenuOpen ? (
<div
@@ -321,7 +323,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 +333,58 @@ 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.expired", "세션 만료")}
</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 +393,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 +418,3 @@ function AppLayout() {
}
export default AppLayout;
// force reload

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"
@@ -1494,13 +1495,12 @@ plane = "Dev Plane"
subtitle = "Manage your applications"
[ui.dev.session]
active = "Checking expiration..."
active = "Session active"
disabled = "Session expiry display 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 = "테넌트 추가"
@@ -1495,12 +1496,11 @@ subtitle = "Manage your applications"
[ui.dev.session]
active = "세션 활성"
disabled = "세션 만료 표시 비활성화"
unknown = "알 수 없음"
expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
refresh = "세션 만료 시간 갱신"
refreshing = "세션 만료 시간 갱신 중..."
[ui.userfront]
app_title = "Baron SW 포탈"

View File

@@ -892,6 +892,7 @@ manageable_tenants = ""
rp_admin = ""
super_admin = ""
tenant_admin = ""
tenant_member = ""
user = ""
[ui.admin.tenants]
@@ -1493,12 +1494,11 @@ subtitle = ""
[ui.dev.session]
active = ""
disabled = ""
unknown = ""
expired = ""
expiring = ""
remaining = ""
refresh = ""
refreshing = ""
[ui.userfront]
app_title = ""