1
0
forked from baron/baron-sso

adminfront에 API Key 관리 및 RBAC 기능 추가

This commit is contained in:
2026-01-28 17:13:53 +09:00
parent 3e95650024
commit df03771121
9 changed files with 548 additions and 14 deletions

View File

@@ -1,9 +1,14 @@
import { createBrowserRouter } from "react-router-dom";
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 AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
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 TenantDetailPage from "../features/tenants/TenantDetailPage";
import TenantListPage from "../features/tenants/TenantListPage";
@@ -21,6 +26,11 @@ export const router = createBrowserRouter(
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ 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 /> },
],
},
],

View File

@@ -1,10 +1,12 @@
import {
BadgeCheck,
Building2,
Key,
KeyRound,
LayoutDashboard,
Moon,
NotebookTabs,
Shield,
ShieldHalf,
Sun,
} from "lucide-react";
@@ -15,6 +17,8 @@ const navItems = [
{ label: "Overview", to: "/", icon: LayoutDashboard },
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
{ 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: "Auth Guard", to: "/auth", icon: KeyRound },
];

View 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;

View 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;

View File

@@ -121,8 +121,9 @@ function AuditLogsPage() {
});
const logs =
data?.pages?.flatMap((page) =>
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
data?.pages?.flatMap(
(page) =>
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
) ?? [];
const handleAddFilter = () => {
@@ -160,12 +161,16 @@ function AuditLogsPage() {
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
Command ClickHouse . /
.
Command ClickHouse . /
.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<Button
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw size={16} />
</Button>
@@ -241,7 +246,7 @@ function AuditLogsPage() {
<TableHead>PATH</TableHead>
<TableHead className="w-[120px]">STATUS</TableHead>
<TableHead>Action / Target</TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
@@ -294,7 +299,9 @@ function AuditLogsPage() {
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label="Copy actor id"
onClick={() =>
handleCopy(row.user_id || details.actor_id || "")
handleCopy(
row.user_id || details.actor_id || "",
)
}
>
<Copy className="h-3 w-3" />
@@ -313,7 +320,9 @@ function AuditLogsPage() {
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label="Copy request id"
onClick={() => handleCopy(details.request_id || "")}
onClick={() =>
handleCopy(details.request_id || "")
}
>
<Copy className="h-3 w-3" />
</Button>
@@ -388,12 +397,15 @@ function AuditLogsPage() {
Request
</div>
<div className="break-all">
Request ID · {formatCellValue(details.request_id)}
Request ID ·{" "}
{formatCellValue(details.request_id)}
</div>
<div className="break-all">
Event ID · {formatCellValue(row.event_id)}
</div>
<div>IP · {formatCellValue(row.ip_address)}</div>
<div>
IP · {formatCellValue(row.ip_address)}
</div>
<div>
Latency ·{" "}
{details.latency_ms !== undefined
@@ -406,10 +418,15 @@ function AuditLogsPage() {
Actor
</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>Tenant · {formatCellValue(details.tenant_id)}</div>
<div>Device · {formatCellValue(row.device_id)}</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
@@ -451,7 +468,6 @@ function AuditLogsPage() {
</div>
</CardContent>
</Card>
</div>
</div>
);

View 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;

View 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;

View 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;

View File

@@ -50,6 +50,35 @@ export type TenantUpdateRequest = {
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) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
@@ -96,3 +125,32 @@ export async function updateTenant(
export async function deleteTenant(tenantId: string) {
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}`);
}