1
0
forked from baron/baron-sso

admin/dev 사이드바 프레임 공통화

This commit is contained in:
2026-05-15 10:16:37 +09:00
parent 0327409631
commit 0bf8089120
5 changed files with 263 additions and 223 deletions

View File

@@ -21,12 +21,14 @@ import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
AppSidebar,
type ShellTranslator,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
type ShellSidebarNavItem,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
@@ -41,19 +43,38 @@ import {
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
interface NavItem {
label: string;
to: string;
icon: React.ComponentType<{ size?: number | string }>;
isExternal?: boolean;
}
const staticNavItems: NavItem[] = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{ 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 },
const staticNavItems: ShellSidebarNavItem[] = [
{
labelKey: "ui.admin.nav.overview",
labelFallback: "Overview",
to: "/",
icon: LayoutDashboard,
end: true,
},
{
labelKey: "ui.admin.nav.users",
labelFallback: "Users",
to: "/users",
icon: Users,
},
{
labelKey: "ui.admin.nav.api_keys",
labelFallback: "API Keys",
to: "/api-keys",
icon: Key,
},
{
labelKey: "ui.admin.nav.audit_logs",
labelFallback: "Audit Logs",
to: "/audit-logs",
icon: NotebookTabs,
},
{
labelKey: "ui.admin.nav.auth_guard",
labelFallback: "Auth Guard",
to: "/auth",
icon: KeyRound,
},
];
type SessionStatusProps = {
@@ -145,7 +166,7 @@ function AppLayout() {
._IS_TEST_MODE === true,
});
const navItems = React.useMemo(() => {
const navItems = React.useMemo<ShellSidebarNavItem[]>(() => {
const items = [...staticNavItems];
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
@@ -167,36 +188,42 @@ function AppLayout() {
if (isSuperAdmin) {
filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants",
labelKey: "ui.admin.nav.tenants",
labelFallback: "Tenants",
to: "/tenants",
icon: Building2,
});
filteredItems.splice(2, 0, {
label: "ui.admin.nav.org_chart",
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
filteredItems.splice(4, 0, {
label: "ui.admin.nav.user_projection",
labelKey: "ui.admin.nav.user_projection",
labelFallback: "User Projection",
to: "/system/projections/users",
icon: Database,
});
filteredItems.splice(5, 0, {
label: "ui.admin.nav.data_integrity",
labelKey: "ui.admin.nav.data_integrity",
labelFallback: "Data Integrity",
to: "/system/data-integrity",
icon: ShieldCheck,
});
} else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) {
filteredItems.splice(1, 0, {
label: "ui.admin.nav.my_tenant",
labelKey: "ui.admin.nav.my_tenant",
labelFallback: "My Tenant",
to: `/tenants/${profile.tenantId}`,
icon: Building2,
});
} else if (manageableCount > 1) {
filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants",
labelKey: "ui.admin.nav.tenants",
labelFallback: "Tenants",
to: "/tenants",
icon: Building2,
});
@@ -205,7 +232,8 @@ function AppLayout() {
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
0,
{
label: "ui.admin.nav.org_chart",
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
@@ -214,7 +242,8 @@ function AppLayout() {
} else {
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
filteredItems.splice(1, 0, {
label: "ui.admin.nav.org_chart",
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
@@ -442,6 +471,66 @@ function AppLayout() {
return next;
});
};
const sidebarNavContent = (
<div className={shellLayoutClasses.navList}>
{navItems.map((item) => {
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
if (isExternal) {
return (
<a
key={to}
href={to}
target="_blank"
rel="noopener noreferrer"
className={[
shellLayoutClasses.navItemBase,
shellLayoutClasses.navItemIdle,
].join(" ")}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</a>
);
}
return (
<NavLink
key={to}
to={to}
end={item.end ?? to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
item.isActive !== undefined
? item.isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle
: isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
);
})}
</div>
);
const sidebarFooterContent = (
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
);
if (auth.isLoading) {
return (
@@ -453,84 +542,13 @@ function AppLayout() {
return (
<div className={shellLayoutClasses.root}>
<aside className={shellLayoutClasses.asideStatic}>
<div className={shellLayoutClasses.brandSection}>
<div className={shellLayoutClasses.brandWrap}>
<div className={shellLayoutClasses.brandIcon}>
<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>
<nav className={shellLayoutClasses.navWrap}>
<div className={shellLayoutClasses.navList}>
{navItems.map((item: NavItem) => {
const { label, to, icon: Icon, isExternal } = item;
const isOrgChart = location.pathname === "/tenants/org-chart";
const isTenantsRoot = to === "/tenants";
const isCustomActive = isTenantsRoot
? location.pathname.startsWith("/tenants") && !isOrgChart
: to === "/"
? location.pathname === "/"
: location.pathname.startsWith(to);
if (isExternal) {
return (
<a
key={to}
href={to}
target="_blank"
rel="noopener noreferrer"
className={[
shellLayoutClasses.navItemBase,
shellLayoutClasses.navItemIdle,
].join(" ")}
>
<Icon size={18} />
<span>{t(label, label)}</span>
</a>
);
}
return (
<NavLink
key={to}
to={to}
className={() =>
[
shellLayoutClasses.navItemBase,
isCustomActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
<Icon size={18} />
<span>{t(label, label)}</span>
</NavLink>
);
})}
</div>
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
</nav>
</aside>
<AppSidebar
brandLabel={t("ui.admin.brand", "Baron 로그인")}
brandTitle={t("ui.admin.title", "Admin Control")}
brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent}
footerContent={sidebarFooterContent}
/>
<div className={shellLayoutClasses.contentWide}>
<header className={shellLayoutClasses.headerElevated}>

View File

@@ -0,0 +1,49 @@
import type { ComponentType, ReactNode } from "react";
import { shellLayoutClasses } from "./layout";
export type ShellSidebarNavItem = {
labelKey: string;
labelFallback: string;
to: string;
icon: ComponentType<{ size?: number | string }>;
isExternal?: boolean;
end?: boolean;
isActive?: boolean;
};
type ShellSidebarProps = {
brandLabel: string;
brandTitle: string;
brandIcon: ReactNode;
navContent: ReactNode;
footerContent: ReactNode;
};
export function AppSidebar({
brandLabel,
brandTitle,
brandIcon,
navContent,
footerContent,
}: ShellSidebarProps) {
return (
<aside className={shellLayoutClasses.aside}>
<div>
<div className={shellLayoutClasses.brandSection}>
<div className={shellLayoutClasses.brandWrap}>
<div className={shellLayoutClasses.brandIcon}>{brandIcon}</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{brandLabel}
</p>
<h1 className="text-lg font-semibold">{brandTitle}</h1>
</div>
</div>
</div>
<nav className={shellLayoutClasses.navWrap}>{navContent}</nav>
</div>
<div>{footerContent}</div>
</aside>
);
}

View File

@@ -22,55 +22,9 @@ type ShellProfileSummaryParams = {
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
export const SHELL_SESSION_EXPIRY_STORAGE_KEY =
"baron_session_expiry_enabled";
export const shellLayoutClasses = {
root: "grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]",
aside:
"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",
asideStatic:
"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",
brandSection:
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
brandIcon:
"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)]",
scopeBadge:
"hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2",
navWrap: "px-2 pb-4 md:px-3 md:pb-8",
navMeta:
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start",
navList: "flex flex-col gap-1",
navItemBase:
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
navItemActive:
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]",
navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
sidebarFooterNotice:
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
logoutButton:
"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",
header: "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
headerElevated:
"sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur",
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
headerTitleWrap: "flex flex-col gap-1",
headerActions: "flex items-center gap-2 text-sm",
actionButton:
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
sessionBadge:
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
profileInitial:
"grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary",
profileMenu:
"absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl",
profileCard:
"mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3",
settingsCard: "mt-2 rounded-lg border border-border px-3 py-3",
content: "relative",
contentWide: "relative min-w-0",
main: "px-5 py-6 md:px-10 md:py-10",
mainMinWidth: "min-w-0 px-5 py-6 md:px-10 md:py-10",
} as const;
export { AppSidebar } from "./AppSidebar";
export type { ShellSidebarNavItem } from "./AppSidebar";
export { shellLayoutClasses } from "./layout";
export function readShellTheme(): ShellTheme {
return window.localStorage.getItem(SHELL_THEME_STORAGE_KEY) === "dark"

48
common/shell/layout.ts Normal file
View File

@@ -0,0 +1,48 @@
export const shellLayoutClasses = {
root: "grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]",
aside:
"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",
asideStatic:
"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",
brandSection:
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
brandIcon:
"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)]",
scopeBadge:
"hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2",
navWrap: "px-2 pb-4 md:px-3 md:pb-8",
navMeta:
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start",
navList: "flex flex-col gap-1",
navItemBase:
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
navItemActive:
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]",
navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
sidebarFooterNotice:
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
logoutButton:
"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",
header: "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
headerElevated:
"sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur",
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
headerTitleWrap: "flex flex-col gap-1",
headerActions: "flex items-center gap-2 text-sm",
actionButton:
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
sessionBadge:
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
profileInitial:
"grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary",
profileMenu:
"absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl",
profileCard:
"mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3",
settingsCard: "mt-2 rounded-lg border border-border px-3 py-3",
content: "relative",
contentWide: "relative min-w-0",
main: "px-5 py-6 md:px-10 md:py-10",
mainMinWidth: "min-w-0 px-5 py-6 md:px-10 md:py-10",
} as const;

View File

@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
ChevronDown,
ClipboardCheck,
LayoutDashboard,
@@ -15,12 +14,14 @@ import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
AppSidebar,
type ShellTranslator,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
type ShellSidebarNavItem,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
@@ -34,12 +35,13 @@ import {
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
const navItems = [
const navItems: ShellSidebarNavItem[] = [
{
labelKey: "ui.dev.nav.overview",
labelFallback: "Overview",
to: "/",
icon: LayoutDashboard,
end: true,
},
{
labelKey: "ui.dev.nav.developer_request",
@@ -323,81 +325,50 @@ function AppLayout() {
return next;
});
};
const sidebarNavContent = (
<div className={shellLayoutClasses.navList}>
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
</div>
);
const sidebarFooterContent = (
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
);
return (
<div className={shellLayoutClasses.root}>
<aside className={shellLayoutClasses.aside}>
<div>
<div className={shellLayoutClasses.brandSection}>
<div className={shellLayoutClasses.brandWrap}>
<div className={shellLayoutClasses.brandIcon}>
<ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.dev.brand", "Baron Sign In")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.dev.console_title", "Developer Console")}
</h1>
</div>
</div>
<div className={shellLayoutClasses.scopeBadge}>
<BadgeCheck size={14} />
{t("ui.dev.scope_badge", "Scoped to /dev")}
</div>
</div>
<nav className={shellLayoutClasses.navWrap}>
<div className={shellLayoutClasses.navMeta}>
<span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")}
</span>
</div>
<div className={shellLayoutClasses.navList}>
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
</div>
</nav>
</div>
<div>
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
<div className={shellLayoutClasses.sidebarFooterNotice}>
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
<p>
{t(
"msg.dev.sidebar.notice_detail",
"Register and manage client applications.",
)}
</p>
</div>
</div>
</aside>
<AppSidebar
brandLabel={t("ui.dev.brand", "Baron Sign In")}
brandTitle={t("ui.dev.console_title", "Developer Console")}
brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent}
footerContent={sidebarFooterContent}
/>
<div className={shellLayoutClasses.content}>
<header className={shellLayoutClasses.header}>