forked from baron/baron-sso
adminfront/devfront 상단바 프로필 메뉴 UI 통일
This commit is contained in:
@@ -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,7 +14,7 @@ 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, useNavigate } from "react-router-dom";
|
||||||
import { fetchMe } from "../../lib/adminApi";
|
import { fetchMe } from "../../lib/adminApi";
|
||||||
@@ -34,23 +33,37 @@ const staticNavItems = [
|
|||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
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 [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 +80,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 +100,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 +115,7 @@ function AppLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return filteredItems;
|
return filteredItems;
|
||||||
}, [profile]);
|
}, [mockRoleOverride, profile]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
if (
|
if (
|
||||||
@@ -142,14 +153,99 @@ 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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
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 +289,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 +304,19 @@ 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 +330,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.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>
|
||||||
</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(`/users/${profile?.id || auth.user?.profile.sub}`);
|
||||||
{t(
|
}}
|
||||||
"ui.admin.profile.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"
|
||||||
"Manageable Tenants",
|
>
|
||||||
)}
|
<UserIcon size={16} className="text-muted-foreground" />
|
||||||
</p>
|
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
|
||||||
<div className="max-h-40 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
|
</button>
|
||||||
{profile.manageableTenants.map((tenant) => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
key={tenant.id}
|
onClick={() => {
|
||||||
type="button"
|
setIsProfileOpen(false);
|
||||||
onClick={() => {
|
handleLogout();
|
||||||
setIsProfileOpen(false);
|
}}
|
||||||
navigate(`/tenants/${tenant.id}`);
|
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"
|
||||||
}}
|
>
|
||||||
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"
|
<LogOut size={16} />
|
||||||
>
|
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||||
<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">
|
</button>
|
||||||
{tenant.type === "USER_GROUP" ? (
|
</div>
|
||||||
<Users size={12} />
|
) : null}
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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 +511,3 @@ function AppLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default AppLayout;
|
export default AppLayout;
|
||||||
// force reload
|
|
||||||
|
|||||||
@@ -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,26 @@ 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 +156,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>
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 @@ plane = "Dev Plane"
|
|||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|
||||||
[ui.dev.session]
|
[ui.dev.session]
|
||||||
active = "Checking expiration..."
|
active = "Session active"
|
||||||
|
disabled = "Session expiry display 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"
|
||||||
|
|||||||
@@ -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 키"
|
||||||
@@ -1465,12 +1466,11 @@ subtitle = "Manage your applications"
|
|||||||
|
|
||||||
[ui.dev.session]
|
[ui.dev.session]
|
||||||
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 포탈"
|
||||||
|
|||||||
@@ -825,6 +825,7 @@ name = ""
|
|||||||
|
|
||||||
[ui.admin.header]
|
[ui.admin.header]
|
||||||
plane = ""
|
plane = ""
|
||||||
|
subtitle = ""
|
||||||
|
|
||||||
[ui.admin.nav]
|
[ui.admin.nav]
|
||||||
api_keys = ""
|
api_keys = ""
|
||||||
@@ -1465,12 +1466,11 @@ subtitle = ""
|
|||||||
|
|
||||||
[ui.dev.session]
|
[ui.dev.session]
|
||||||
active = ""
|
active = ""
|
||||||
|
disabled = ""
|
||||||
unknown = ""
|
unknown = ""
|
||||||
expired = ""
|
expired = ""
|
||||||
expiring = ""
|
expiring = ""
|
||||||
remaining = ""
|
remaining = ""
|
||||||
refresh = ""
|
|
||||||
refreshing = ""
|
|
||||||
|
|
||||||
[ui.userfront]
|
[ui.userfront]
|
||||||
app_title = ""
|
app_title = ""
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -15,7 +16,6 @@ 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 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 = [
|
||||||
@@ -42,7 +42,10 @@ function AppLayout() {
|
|||||||
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);
|
||||||
@@ -100,19 +103,19 @@ function AppLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 +135,55 @@ 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(
|
||||||
setIsRefreshingSession(true);
|
"baron_session_expiry_enabled",
|
||||||
try {
|
String(next),
|
||||||
await auth.signinSilent();
|
);
|
||||||
setNowMs(Date.now());
|
return next;
|
||||||
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 +234,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 +280,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 +310,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 +323,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 +333,58 @@ 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.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>
|
||||||
</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 +393,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 +418,3 @@ function AppLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default AppLayout;
|
export default AppLayout;
|
||||||
// force reload
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1494,13 +1495,12 @@ plane = "Dev Plane"
|
|||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|
||||||
[ui.dev.session]
|
[ui.dev.session]
|
||||||
active = "Checking expiration..."
|
active = "Session active"
|
||||||
|
disabled = "Session expiry display 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"
|
||||||
|
|||||||
@@ -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 = "테넌트 추가"
|
||||||
@@ -1495,12 +1496,11 @@ subtitle = "Manage your applications"
|
|||||||
|
|
||||||
[ui.dev.session]
|
[ui.dev.session]
|
||||||
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 포탈"
|
||||||
|
|||||||
@@ -892,6 +892,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]
|
||||||
@@ -1493,12 +1494,11 @@ subtitle = ""
|
|||||||
|
|
||||||
[ui.dev.session]
|
[ui.dev.session]
|
||||||
active = ""
|
active = ""
|
||||||
|
disabled = ""
|
||||||
unknown = ""
|
unknown = ""
|
||||||
expired = ""
|
expired = ""
|
||||||
expiring = ""
|
expiring = ""
|
||||||
remaining = ""
|
remaining = ""
|
||||||
refresh = ""
|
|
||||||
refreshing = ""
|
|
||||||
|
|
||||||
[ui.userfront]
|
[ui.userfront]
|
||||||
app_title = ""
|
app_title = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user