generated from gc/template-java-maven
- JWT 인증 및 LoginPage 제거, SecurityConfig permitAll 전환 - @PreAuthorize 어노테이션 전체 제거 (@EnableMethodSecurity 비활성화) - ADMIN/MANAGER/USER 역할 토글 버튼 (헤더) + localStorage 연동 - X-User-Id 헤더 기반 사용자 식별 (ApiKeyController, ApiKeyRequestController) - RoleGuard 컴포넌트로 관리자 전용 페이지 접근 제어 - WebViewController 루트 리다이렉트 수정 (이중 context-path 방지) closes #35
200 lines
7.7 KiB
TypeScript
200 lines
7.7 KiB
TypeScript
import { useState } from 'react';
|
|
import { Outlet, NavLink } from 'react-router-dom';
|
|
import { useAuth } from '../hooks/useAuth';
|
|
import { useTheme } from '../hooks/useTheme';
|
|
|
|
interface NavGroup {
|
|
label: string;
|
|
items: { label: string; path: string }[];
|
|
adminOnly?: boolean;
|
|
}
|
|
|
|
const navGroups: NavGroup[] = [
|
|
{
|
|
label: 'Monitoring',
|
|
items: [
|
|
{ label: 'Request Logs', path: '/monitoring/request-logs' },
|
|
{ label: 'Service Status', path: '/monitoring/service-status' },
|
|
],
|
|
},
|
|
{
|
|
label: 'Statistics',
|
|
items: [
|
|
{ label: '서비스 통계', path: '/statistics/services' },
|
|
{ label: '사용자 통계', path: '/statistics/users' },
|
|
{ label: 'API 통계', path: '/statistics/apis' },
|
|
{ label: '테넌트 통계', path: '/statistics/tenants' },
|
|
{ label: '사용량 추이', path: '/statistics/usage-trend' },
|
|
],
|
|
},
|
|
{
|
|
label: 'API Keys',
|
|
items: [
|
|
{ label: 'My Keys', path: '/apikeys/my-keys' },
|
|
{ label: 'Request', path: '/apikeys/request' },
|
|
{ label: 'Admin', path: '/apikeys/admin' },
|
|
],
|
|
},
|
|
{
|
|
label: 'Admin',
|
|
adminOnly: true,
|
|
items: [
|
|
{ label: 'Services', path: '/admin/services' },
|
|
{ label: 'Users', path: '/admin/users' },
|
|
{ label: 'Tenants', path: '/admin/tenants' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
|
|
|
|
const MainLayout = () => {
|
|
const { user, setRole } = useAuth();
|
|
const { theme, toggleTheme } = useTheme();
|
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
|
Monitoring: true,
|
|
Statistics: true,
|
|
'API Keys': true,
|
|
Admin: true,
|
|
});
|
|
|
|
const toggleGroup = (label: string) => {
|
|
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
|
|
};
|
|
|
|
const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
|
|
|
return (
|
|
<div className="flex min-h-screen">
|
|
{/* Sidebar */}
|
|
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col">
|
|
<div className="flex items-center gap-2 px-6 h-16 border-b border-gray-700">
|
|
<svg className="h-6 w-6" style={{ color: '#FF2E63' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
</svg>
|
|
<span className="text-lg font-semibold">SNP Connection</span>
|
|
</div>
|
|
|
|
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
|
{/* Dashboard */}
|
|
<NavLink
|
|
to="/dashboard"
|
|
className={({ isActive }) =>
|
|
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
|
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
|
}`
|
|
}
|
|
>
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
|
</svg>
|
|
Dashboard
|
|
</NavLink>
|
|
|
|
{/* Nav Groups */}
|
|
{navGroups.map((group) => {
|
|
if (group.adminOnly && user?.role !== 'ADMIN') return null;
|
|
|
|
const isOpen = openGroups[group.label] ?? false;
|
|
|
|
return (
|
|
<div key={group.label} className="mt-4">
|
|
<button
|
|
onClick={() => toggleGroup(group.label)}
|
|
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-400 hover:text-white"
|
|
>
|
|
{group.label}
|
|
<svg
|
|
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="ml-2 space-y-1">
|
|
{group.items.map((item) => {
|
|
if (
|
|
group.label === 'API Keys' &&
|
|
item.label === 'Admin' &&
|
|
!isAdminOrManager
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<NavLink
|
|
key={item.path}
|
|
to={item.path}
|
|
className={({ isActive }) =>
|
|
`block rounded-lg px-3 py-2 text-sm transition-colors ${
|
|
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
|
}`
|
|
}
|
|
>
|
|
{item.label}
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 ml-64">
|
|
{/* Header */}
|
|
<header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between px-6">
|
|
<div />
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="rounded-lg p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
|
|
>
|
|
{theme === 'light' ? (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">Role:</span>
|
|
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
|
{ROLES.map((role) => (
|
|
<button
|
|
key={role}
|
|
onClick={() => setRole(role)}
|
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
user?.role === role
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
{role}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content */}
|
|
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MainLayout;
|