1
0
forked from baron/baron-sso

adminfront ui 개편

This commit is contained in:
2026-03-20 10:58:52 +09:00
parent 918f133867
commit d0e4f8f86a
11 changed files with 142 additions and 298 deletions

View File

@@ -39,6 +39,18 @@ function AppLayout() {
return stored === "dark" ? "dark" : "light";
});
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [timeLeft, setTimeLeft] = useState<number | null>(null);
const expiresAt = auth.user?.expires_at;
useEffect(() => {
if (!expiresAt) return;
const updateTimer = () => {
setTimeLeft(Math.max(0, Math.floor(expiresAt - Date.now() / 1000)));
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -156,20 +168,8 @@ function AppLayout() {
</h1>
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
{t("msg.admin.scope_admin", "Scoped to /admin")}
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.idp_env_prod", "IDP env: prod")}
</span>
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.tenant_headers", "Tenant-aware headers")}
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => (
<NavLink
@@ -201,36 +201,11 @@ function AppLayout() {
</button>
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p>
{t(
"msg.admin.notice.scope",
"관리 기능은 /admin 네임스페이스에서만 노출합니다.",
)}
</p>
<p>
{t(
"msg.admin.notice.idp_policy",
"IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다.",
)}
</p>
</div>
</aside>
<div className="relative">
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<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(
"msg.admin.header.subtitle",
"Tenant isolation & least privilege by default",
)}
</span>
</div>
<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 gap-2 text-sm">
<LanguageSelector />
<button
@@ -266,7 +241,7 @@ function AppLayout() {
{isProfileOpen && (
<>
<div
className="fixed inset-0 z-30"
className="fixed inset-0 z-[90]"
onClick={() => setIsProfileOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setIsProfileOpen(false);
@@ -275,7 +250,7 @@ function AppLayout() {
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-40 animate-in fade-in zoom-in-95 duration-200">
<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}
@@ -364,8 +339,10 @@ function AppLayout() {
)}
</div>
<span className="hidden md:inline-flex rounded-full border border-border px-3 py-2 text-muted-foreground">
{t("msg.admin.session_ttl", "Session TTL: 15m admin")}
<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>

View File

@@ -66,15 +66,6 @@ function ApiKeyListPage() {
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>
{t("ui.admin.api_keys.list.breadcrumb.section", "API Keys")}
</span>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.api_keys.list.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold">
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
</h2>

View File

@@ -161,13 +161,6 @@ function AuditLogsPage() {
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div>
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>{t("ui.admin.audit.breadcrumb.section", "Audit")}</span>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.audit.breadcrumb.logs", "Logs")}
</span>
</div>
<h2 className="text-3xl font-semibold">
{t("ui.admin.audit.title", "감사 로그")}
</h2>

View File

@@ -1,15 +1,14 @@
import {
Activity,
ArrowUpRight,
Box,
Database,
Key,
PlusCircle,
ShieldCheck,
Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
@@ -22,244 +21,180 @@ import PermissionChecker from "./components/PermissionChecker";
function GlobalOverviewPage() {
return (
<div className="space-y-10">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
{t("ui.admin.overview.kicker", "Global Overview")}
</p>
<h2 className="text-3xl font-semibold">
{t("ui.admin.overview.title", "Tenant-independent control plane")}
<div className="space-y-8 animate-in fade-in duration-500">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.overview.title", "Dashboard")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
<p className="text-muted-foreground">
{t(
"msg.admin.overview.description",
"모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
)}
</p>
</div>
<RoleGuard roles={["super_admin"]}>
<div className="flex items-center gap-2">
<Badge variant="muted">
{t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
</Badge>
<Badge variant="muted">
{t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
</Badge>
</div>
</RoleGuard>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<RoleGuard roles={["super_admin"]}>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>
{t("ui.admin.overview.summary.total_tenants", "Total Tenants")}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")}
</CardTitle>
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Users size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">-</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t(
"msg.admin.overview.summary.total_tenants",
"Tenant-aware core",
)}
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>
{t("ui.admin.overview.summary.oidc_clients", "OIDC Clients")}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
</CardTitle>
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
<ShieldCheck size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">-</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t("msg.admin.overview.summary.oidc_clients", "Hydra registry")}
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
OIDC
</p>
</CardContent>
</Card>
</RoleGuard>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t(
"ui.admin.overview.summary.audit_events_24h",
"Audit Events (24h)",
"최근 감사 로그 (24h)",
)}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
</CardTitle>
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
<Activity size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">-</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t(
"msg.admin.overview.summary.audit_events_24h",
"ClickHouse stream",
)}
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>
{t("ui.admin.overview.summary.policy_gate", "Policy Gate")}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
</CardTitle>
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
<Database size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">Planned</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t(
"msg.admin.overview.summary.policy_gate",
"Keto + Admin checks",
)}
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
Active
</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl">
{t("ui.admin.overview.playbook.title", "Admin playbook")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.overview.playbook.description",
"운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<ShieldCheck size={14} />
<div className="space-y-4">
<h3 className="text-lg font-semibold tracking-tight">
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<RoleGuard roles={["super_admin"]}>
<Link
to="/tenants/new"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<PlusCircle size={20} />
</div>
<div>
<p className="font-semibold text-foreground">
{t(
"msg.admin.overview.playbook.idp_title",
"Backend-only IDP access",
)}
</p>
<p>
{t(
"msg.admin.overview.playbook.idp_body",
"모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.",
)}
<h4 className="font-semibold transition-colors group-hover:text-primary">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<Box size={14} />
</div>
<div>
<p className="font-semibold text-foreground">
{t(
"msg.admin.overview.playbook.tenant_title",
"Tenant isolation",
)}
</p>
<p>
{t(
"msg.admin.overview.playbook.tenant_body",
"Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.",
)}
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
</RoleGuard>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl">
{t("ui.admin.overview.quick_links.title", "빠른 이동")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.overview.quick_links.description",
"주요 운영 화면으로 바로 이동합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<RoleGuard roles={["super_admin"]}>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/tenants/new">
{t("ui.admin.overview.quick_links.add_tenant", "테넌트 추가")}
<ArrowUpRight size={16} />
</Link>
</Button>
</RoleGuard>
<Button
asChild
className="w-full justify-between"
variant="outline"
<Link
to="/users"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
<Users size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
<RoleGuard roles={["super_admin"]}>
<Link
to="/api-keys"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
>
<Link to="/users">
{t(
"ui.admin.overview.quick_links.user_management",
"사용자 관리",
)}
<ArrowUpRight size={16} />
</Link>
</Button>
<RoleGuard roles={["super_admin"]}>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/api-keys">
{t(
"ui.admin.overview.quick_links.api_key_management",
"API 키 관리",
)}
<ArrowUpRight size={16} />
</Link>
</Button>
</RoleGuard>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/audit-logs">
{t(
"ui.admin.overview.quick_links.view_audit_logs",
"감사 로그 보기",
)}
<ArrowUpRight size={16} />
</Link>
</Button>
</CardContent>
</Card>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
<Key size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
API
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/audit-logs"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
<Activity size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</div>
</div>
<RoleGuard roles={["super_admin"]}>
<PermissionChecker />
<div className="pt-4">
<PermissionChecker />
</div>
</RoleGuard>
</div>
);

View File

@@ -58,15 +58,6 @@ function TenantCreatePage() {
return (
<div className="space-y-8">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>
{t("ui.admin.tenants.create.breadcrumb.section", "Tenants")}
</span>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.tenants.create.breadcrumb.action", "Create")}
</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-3xl font-semibold">

View File

@@ -32,19 +32,6 @@ function TenantDetailPage() {
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link
to="/tenants"
className="inline-flex items-center gap-2 hover:text-foreground transition-colors"
>
<ArrowLeft size={14} />
{t("ui.admin.tenants.detail.breadcrumb_list", "테넌트 목록")}
</Link>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.tenants.detail.title", "상세")}
</span>
</div>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}

View File

@@ -119,13 +119,6 @@ function TenantListPage() {
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>{t("ui.admin.tenants.breadcrumb.section", "Tenants")}</span>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.tenants.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.title", "테넌트 목록")}
</h2>

View File

@@ -177,15 +177,6 @@ function UserCreatePage() {
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
{t("ui.admin.users.create.breadcrumb.section", "Users")}
</Link>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.users.create.breadcrumb.new", "New")}
</span>
</div>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.create.title", "사용자 추가")}
</h2>

View File

@@ -315,13 +315,6 @@ function UserDetailPage() {
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
{t("ui.admin.users.detail.breadcrumb.section", "Users")}
</Link>
<span>/</span>
<span className="text-foreground">{user.name}</span>
</div>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.detail.title", "사용자 상세")}
</h2>

View File

@@ -257,13 +257,6 @@ function UserListPage() {
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>{t("ui.admin.users.list.breadcrumb.section", "Users")}</span>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.users.list.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold" data-testid="page-title">
{t("ui.admin.users.list.title", "사용자 관리")}
</h2>

View File

@@ -880,7 +880,7 @@ start_import = "임포트 시작"
[ui.admin.overview]
kicker = "Global Overview"
title = "Tenant-independent control plane"
title = "통합 대시보드"
[ui.admin.overview.playbook]
title = "Admin playbook"