forked from baron/baron-sso
adminfront에 API Key 관리 및 RBAC 기능 추가
This commit is contained in:
@@ -1,9 +1,14 @@
|
|||||||
import { createBrowserRouter } from "react-router-dom";
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
import AppLayout from "../components/layout/AppLayout";
|
import AppLayout from "../components/layout/AppLayout";
|
||||||
|
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
||||||
|
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||||
import AuthPage from "../features/auth/AuthPage";
|
import AuthPage from "../features/auth/AuthPage";
|
||||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||||
|
import RoleCreatePage from "../features/roles/RoleCreatePage";
|
||||||
|
import RoleDetailPage from "../features/roles/RoleDetailPage";
|
||||||
|
import RoleListPage from "../features/roles/RoleListPage";
|
||||||
import TenantCreatePage from "../features/tenants/TenantCreatePage";
|
import TenantCreatePage from "../features/tenants/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/TenantDetailPage";
|
import TenantDetailPage from "../features/tenants/TenantDetailPage";
|
||||||
import TenantListPage from "../features/tenants/TenantListPage";
|
import TenantListPage from "../features/tenants/TenantListPage";
|
||||||
@@ -21,6 +26,11 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
{ path: "tenants/:id", element: <TenantDetailPage /> },
|
{ path: "tenants/:id", element: <TenantDetailPage /> },
|
||||||
|
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||||
|
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||||
|
{ path: "roles", element: <RoleListPage /> },
|
||||||
|
{ path: "roles/new", element: <RoleCreatePage /> },
|
||||||
|
{ path: "roles/:id", element: <RoleDetailPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Building2,
|
Building2,
|
||||||
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Moon,
|
Moon,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
|
Shield,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -15,6 +17,8 @@ const navItems = [
|
|||||||
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
||||||
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
||||||
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
||||||
|
{ label: "Roles & RBAC", to: "/roles", icon: Shield },
|
||||||
|
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
||||||
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
||||||
];
|
];
|
||||||
|
|||||||
34
adminfront/src/features/api-keys/ApiKeyCreatePage.tsx
Normal file
34
adminfront/src/features/api-keys/ApiKeyCreatePage.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
|
function ApiKeyCreatePage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<Link
|
||||||
|
to="/api-keys"
|
||||||
|
className="flex items-center gap-1 text-sm text-[var(--color-muted)] hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
Back to list
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-semibold">새 API 키 생성</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
새로운 Machine-to-Machine 통신용 API 키를 설정합니다.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed p-12 text-center">
|
||||||
|
<p className="text-[var(--color-muted)]">
|
||||||
|
API 키 생성 폼이 여기에 구현될 예정입니다.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" disabled>
|
||||||
|
생성하기 (준비 중)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeyCreatePage;
|
||||||
172
adminfront/src/features/api-keys/ApiKeyListPage.tsx
Normal file
172
adminfront/src/features/api-keys/ApiKeyListPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
function ApiKeyListPage() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
||||||
|
queryFn: () => fetchApiKeys(50, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteApiKey(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
query.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
|
?.data?.error;
|
||||||
|
const fallbackError =
|
||||||
|
!errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null;
|
||||||
|
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
|
||||||
|
const handleDelete = (id: string, name: string) => {
|
||||||
|
if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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)]">
|
||||||
|
<span>API Keys</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">List</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">API 키 관리 (M2M)</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고
|
||||||
|
관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => query.refetch()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/api-keys/new">
|
||||||
|
<Plus size={16} />
|
||||||
|
API 키 생성
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>API Key Registry</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
총 {query.data?.total ?? 0}개 API 키
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">System</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(errorMsg || fallbackError) && (
|
||||||
|
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg ?? fallbackError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>NAME</TableHead>
|
||||||
|
<TableHead>CLIENT ID</TableHead>
|
||||||
|
<TableHead>SCOPES</TableHead>
|
||||||
|
<TableHead>LAST USED</TableHead>
|
||||||
|
<TableHead className="text-right">ACTIONS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{query.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!query.isLoading && items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>등록된 API 키가 없습니다.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{items.map((key) => (
|
||||||
|
<TableRow key={key.id}>
|
||||||
|
<TableCell className="font-semibold">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Key size={14} className="text-[var(--color-muted)]" />
|
||||||
|
{key.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code>{key.client_id}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{key.scopes.map((scope) => (
|
||||||
|
<Badge
|
||||||
|
key={scope}
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{scope}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{key.lastUsedAt
|
||||||
|
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||||
|
: "Never"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(key.id, key.name)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeyListPage;
|
||||||
@@ -121,8 +121,9 @@ function AuditLogsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const logs =
|
const logs =
|
||||||
data?.pages?.flatMap((page) =>
|
data?.pages?.flatMap(
|
||||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
(page) =>
|
||||||
|
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
const handleAddFilter = () => {
|
const handleAddFilter = () => {
|
||||||
@@ -160,12 +161,16 @@ function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-semibold">감사 로그</h2>
|
<h2 className="text-3xl font-semibold">감사 로그</h2>
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는
|
Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후
|
||||||
추후 세션 연동 시 자동 채워집니다.
|
세션 연동 시 자동 채워집니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
@@ -241,7 +246,7 @@ function AuditLogsPage() {
|
|||||||
<TableHead>PATH</TableHead>
|
<TableHead>PATH</TableHead>
|
||||||
<TableHead className="w-[120px]">STATUS</TableHead>
|
<TableHead className="w-[120px]">STATUS</TableHead>
|
||||||
<TableHead>Action / Target</TableHead>
|
<TableHead>Action / Target</TableHead>
|
||||||
<TableHead className="w-[80px]"></TableHead>
|
<TableHead className="w-[80px]" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -294,7 +299,9 @@ function AuditLogsPage() {
|
|||||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
aria-label="Copy actor id"
|
aria-label="Copy actor id"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleCopy(row.user_id || details.actor_id || "")
|
handleCopy(
|
||||||
|
row.user_id || details.actor_id || "",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
@@ -313,7 +320,9 @@ function AuditLogsPage() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
aria-label="Copy request id"
|
aria-label="Copy request id"
|
||||||
onClick={() => handleCopy(details.request_id || "")}
|
onClick={() =>
|
||||||
|
handleCopy(details.request_id || "")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -388,12 +397,15 @@ function AuditLogsPage() {
|
|||||||
Request
|
Request
|
||||||
</div>
|
</div>
|
||||||
<div className="break-all">
|
<div className="break-all">
|
||||||
Request ID · {formatCellValue(details.request_id)}
|
Request ID ·{" "}
|
||||||
|
{formatCellValue(details.request_id)}
|
||||||
</div>
|
</div>
|
||||||
<div className="break-all">
|
<div className="break-all">
|
||||||
Event ID · {formatCellValue(row.event_id)}
|
Event ID · {formatCellValue(row.event_id)}
|
||||||
</div>
|
</div>
|
||||||
<div>IP · {formatCellValue(row.ip_address)}</div>
|
<div>
|
||||||
|
IP · {formatCellValue(row.ip_address)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Latency ·{" "}
|
Latency ·{" "}
|
||||||
{details.latency_ms !== undefined
|
{details.latency_ms !== undefined
|
||||||
@@ -406,10 +418,15 @@ function AuditLogsPage() {
|
|||||||
Actor
|
Actor
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Actor ID · {row.user_id || details.actor_id || "-"}
|
Actor ID ·{" "}
|
||||||
|
{row.user_id || details.actor_id || "-"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Tenant · {formatCellValue(details.tenant_id)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Device · {formatCellValue(row.device_id)}
|
||||||
</div>
|
</div>
|
||||||
<div>Tenant · {formatCellValue(details.tenant_id)}</div>
|
|
||||||
<div>Device · {formatCellValue(row.device_id)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="uppercase tracking-[0.16em]">
|
<div className="uppercase tracking-[0.16em]">
|
||||||
@@ -451,7 +468,6 @@ function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
34
adminfront/src/features/roles/RoleCreatePage.tsx
Normal file
34
adminfront/src/features/roles/RoleCreatePage.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
|
function RoleCreatePage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<Link
|
||||||
|
to="/roles"
|
||||||
|
className="flex items-center gap-1 text-sm text-[var(--color-muted)] hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
Back to list
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-semibold">새 역할 추가</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
새로운 사용자 역할을 정의하고 권한을 할당합니다.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed p-12 text-center">
|
||||||
|
<p className="text-[var(--color-muted)]">
|
||||||
|
역할 생성 폼이 여기에 구현될 예정입니다.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" disabled>
|
||||||
|
추가하기 (준비 중)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleCreatePage;
|
||||||
34
adminfront/src/features/roles/RoleDetailPage.tsx
Normal file
34
adminfront/src/features/roles/RoleDetailPage.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
|
function RoleDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<Link
|
||||||
|
to="/roles"
|
||||||
|
className="flex items-center gap-1 text-sm text-[var(--color-muted)] hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
Back to list
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-semibold">역할 상세</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">역할 ID: {id}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed p-12 text-center">
|
||||||
|
<p className="text-[var(--color-muted)]">
|
||||||
|
역할 상세 정보 및 권한 편집 화면이 여기에 구현될 예정입니다.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" variant="outline" disabled>
|
||||||
|
수정하기 (준비 중)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleDetailPage;
|
||||||
172
adminfront/src/features/roles/RoleListPage.tsx
Normal file
172
adminfront/src/features/roles/RoleListPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { Plus, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import { deleteRole, fetchRoles } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
function RoleListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["roles", { limit: 50, offset: 0 }],
|
||||||
|
queryFn: () => fetchRoles(50, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteRole(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
query.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
|
?.data?.error;
|
||||||
|
const fallbackError =
|
||||||
|
!errorMsg && query.isError ? "역할 목록 조회에 실패했습니다." : null;
|
||||||
|
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
|
||||||
|
const handleDelete = (id: string, name: string) => {
|
||||||
|
if (!window.confirm(`역할 "${name}"을 삭제할까요?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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)]">
|
||||||
|
<span>Roles</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">List</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">역할 및 권한 (RBAC)</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
사용자에게 부여할 역할과 해당 역할의 세부 권한을 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => query.refetch()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/roles/new">
|
||||||
|
<Plus size={16} />
|
||||||
|
역할 추가
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Role Management</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
총 {query.data?.total ?? 0}개 역할 정의됨
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">RBAC</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(errorMsg || fallbackError) && (
|
||||||
|
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg ?? fallbackError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ROLE NAME</TableHead>
|
||||||
|
<TableHead>DESCRIPTION</TableHead>
|
||||||
|
<TableHead>PERMISSIONS</TableHead>
|
||||||
|
<TableHead className="text-right">ACTIONS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{query.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4}>로딩 중...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!query.isLoading && items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4}>정의된 역할이 없습니다.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{items.map((role) => (
|
||||||
|
<TableRow key={role.id}>
|
||||||
|
<TableCell className="font-semibold">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield size={14} className="text-[var(--color-muted)]" />
|
||||||
|
{role.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{role.description}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{role.permissions.map((p) => (
|
||||||
|
<Badge
|
||||||
|
key={p}
|
||||||
|
variant="default"
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/roles/${role.id}`)}
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(role.id, role.name)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleListPage;
|
||||||
@@ -50,6 +50,35 @@ export type TenantUpdateRequest = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiKeySummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
client_id: string;
|
||||||
|
scopes: string[];
|
||||||
|
status: string;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyListResponse = {
|
||||||
|
items: ApiKeySummary[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
permissions: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleListResponse = {
|
||||||
|
items: RoleSummary[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||||
params: { limit, cursor },
|
params: { limit, cursor },
|
||||||
@@ -96,3 +125,32 @@ export async function updateTenant(
|
|||||||
export async function deleteTenant(tenantId: string) {
|
export async function deleteTenant(tenantId: string) {
|
||||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API Key Management (M2M)
|
||||||
|
export async function fetchApiKeys(limit = 50, offset = 0) {
|
||||||
|
// Placeholder implementation
|
||||||
|
const { data } = await apiClient.get<ApiKeyListResponse>(
|
||||||
|
"/v1/admin/api-keys",
|
||||||
|
{
|
||||||
|
params: { limit, offset },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApiKey(apiKeyId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role Management (RBAC)
|
||||||
|
export async function fetchRoles(limit = 50, offset = 0) {
|
||||||
|
// Placeholder implementation
|
||||||
|
const { data } = await apiClient.get<RoleListResponse>("/v1/admin/roles", {
|
||||||
|
params: { limit, offset },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRole(roleId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/roles/${roleId}`);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user