forked from baron/baron-sso
i18n refresh and frontend fixes
This commit is contained in:
@@ -1,148 +1,161 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Moon,
|
||||
NotebookTabs,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
Users,
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Moon,
|
||||
NotebookTabs,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
||||
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
||||
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
||||
{ label: "Users", to: "/users", icon: Users },
|
||||
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
||||
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||
{
|
||||
label: "ui.admin.nav.tenant_dashboard",
|
||||
to: "/dashboard",
|
||||
icon: ShieldHalf,
|
||||
},
|
||||
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
|
||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
||||
];
|
||||
|
||||
function AppLayout() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
} else {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
}, [theme]);
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
} else {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
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">
|
||||
<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="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Baron 로그인
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
Admin Control
|
||||
</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} />
|
||||
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">
|
||||
IDP env: prod
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
Tenant-aware headers
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
||||
<p>관리 기능은 /admin 네임스페이스에서만 노출합니다.</p>
|
||||
<p>
|
||||
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">
|
||||
Admin Plane
|
||||
</p>
|
||||
<span className="text-lg font-semibold">
|
||||
Tenant isolation & least privilege by default
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
aria-label="테마 전환"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<Sun size={16} />
|
||||
) : (
|
||||
<Moon size={16} />
|
||||
)}
|
||||
{theme === "light" ? "Light" : "Dark"}
|
||||
</button>
|
||||
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
|
||||
Session TTL: 15m admin
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
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">
|
||||
<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="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("ui.admin.brand", "Baron 로그인")}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("ui.admin.title", "Admin Control")}
|
||||
</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
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</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>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
{theme === "light"
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
|
||||
{t("msg.admin.session_ttl", "Session TTL: 15m admin")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
|
||||
@@ -1,64 +1,86 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const RoleSwitcher: React.FC = () => {
|
||||
const [currentRole, setCurrentRole] = useState<string>('super_admin');
|
||||
const RoleSwitcher: FC = () => {
|
||||
const [currentRole, setCurrentRole] = useState<string>("super_admin");
|
||||
|
||||
useEffect(() => {
|
||||
// localStorage에서 역할 읽기
|
||||
const savedRole = window.localStorage.getItem('X-Mock-Role');
|
||||
const savedRole = window.localStorage.getItem("X-Mock-Role");
|
||||
if (savedRole) {
|
||||
setCurrentRole(savedRole);
|
||||
} else {
|
||||
// 기본값 설정
|
||||
window.localStorage.setItem('X-Mock-Role', 'super_admin');
|
||||
window.localStorage.setItem("X-Mock-Role", "super_admin");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const switchRole = (role: string) => {
|
||||
// localStorage 설정
|
||||
window.localStorage.setItem('X-Mock-Role', role);
|
||||
window.localStorage.setItem("X-Mock-Role", role);
|
||||
setCurrentRole(role);
|
||||
// 페이지 새로고침하여 권한 적용
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (import.meta.env.MODE === 'production') return null;
|
||||
if (import.meta.env.MODE === "production") return null;
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"),
|
||||
tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"),
|
||||
rp_admin: t("ui.admin.role.rp_admin", "RP ADMIN"),
|
||||
tenant_member: t("ui.admin.role.tenant_member", "TENANT MEMBER"),
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
zIndex: 9999,
|
||||
background: '#1A1F2C',
|
||||
color: 'white',
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', borderBottom: '1px solid #444', paddingBottom: '4px', marginBottom: '4px' }}>
|
||||
🛠 DEV Role Switcher
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
zIndex: 9999,
|
||||
background: "#1A1F2C",
|
||||
color: "white",
|
||||
padding: "10px",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
borderBottom: "1px solid #444",
|
||||
paddingBottom: "4px",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{t("ui.admin.dev_role_switcher", "🛠 DEV Role Switcher")}
|
||||
</div>
|
||||
{(['super_admin', 'tenant_admin', 'rp_admin', 'tenant_member'] as const).map(role => (
|
||||
{(
|
||||
["super_admin", "tenant_admin", "rp_admin", "tenant_member"] as const
|
||||
).map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
onClick={() => switchRole(role)}
|
||||
style={{
|
||||
background: currentRole === role ? '#3b82f6' : '#333',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'background 0.2s'
|
||||
background: currentRole === role ? "#3b82f6" : "#333",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
>
|
||||
{role.toUpperCase().replace('_', ' ')} {currentRole === role ? '✅' : ''}
|
||||
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}{" "}
|
||||
{currentRole === role ? "✅" : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user