From df037711218572283dc36a0049cebaa3ed6ad3ba Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 28 Jan 2026 17:13:53 +0900 Subject: [PATCH] =?UTF-8?q?adminfront=EC=97=90=20API=20Key=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20RBAC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 10 + .../src/components/layout/AppLayout.tsx | 4 + .../features/api-keys/ApiKeyCreatePage.tsx | 34 ++++ .../src/features/api-keys/ApiKeyListPage.tsx | 172 ++++++++++++++++++ .../src/features/audit/AuditLogsPage.tsx | 44 +++-- .../src/features/roles/RoleCreatePage.tsx | 34 ++++ .../src/features/roles/RoleDetailPage.tsx | 34 ++++ .../src/features/roles/RoleListPage.tsx | 172 ++++++++++++++++++ adminfront/src/lib/adminApi.ts | 58 ++++++ 9 files changed, 548 insertions(+), 14 deletions(-) create mode 100644 adminfront/src/features/api-keys/ApiKeyCreatePage.tsx create mode 100644 adminfront/src/features/api-keys/ApiKeyListPage.tsx create mode 100644 adminfront/src/features/roles/RoleCreatePage.tsx create mode 100644 adminfront/src/features/roles/RoleDetailPage.tsx create mode 100644 adminfront/src/features/roles/RoleListPage.tsx diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index a390a46e..165589ab 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -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: }, { path: "tenants/new", element: }, { path: "tenants/:id", element: }, + { path: "api-keys", element: }, + { path: "api-keys/new", element: }, + { path: "roles", element: }, + { path: "roles/new", element: }, + { path: "roles/:id", element: }, ], }, ], diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index a412692a..e2feb40d 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -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 }, ]; diff --git a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx new file mode 100644 index 00000000..81a0c072 --- /dev/null +++ b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx @@ -0,0 +1,34 @@ +import { ChevronLeft } from "lucide-react"; +import { Link } from "react-router-dom"; +import { Button } from "../../components/ui/button"; + +function ApiKeyCreatePage() { + return ( +
+
+ + + Back to list + +

새 API 키 생성

+

+ 새로운 Machine-to-Machine 통신용 API 키를 설정합니다. +

+
+ +
+

+ API 키 생성 폼이 여기에 구현될 예정입니다. +

+ +
+
+ ); +} + +export default ApiKeyCreatePage; diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.tsx new file mode 100644 index 00000000..1be12120 --- /dev/null +++ b/adminfront/src/features/api-keys/ApiKeyListPage.tsx @@ -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 ( +
+
+
+
+ API Keys + / + List +
+

API 키 관리 (M2M)

+

+ 서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 + 관리합니다. +

+
+
+ + +
+
+ + + +
+ API Key Registry + + 총 {query.data?.total ?? 0}개 API 키 + +
+ System +
+ + {(errorMsg || fallbackError) && ( +
+ {errorMsg ?? fallbackError} +
+ )} + + + + + NAME + CLIENT ID + SCOPES + LAST USED + ACTIONS + + + + {query.isLoading && ( + + 로딩 중... + + )} + {!query.isLoading && items.length === 0 && ( + + 등록된 API 키가 없습니다. + + )} + {items.map((key) => ( + + +
+ + {key.name} +
+
+ + {key.client_id} + + +
+ {key.scopes.map((scope) => ( + + {scope} + + ))} +
+
+ + {key.lastUsedAt + ? new Date(key.lastUsedAt).toLocaleString("ko-KR") + : "Never"} + + + + +
+ ))} +
+
+
+
+
+ ); +} + +export default ApiKeyListPage; diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index a504554a..9d937c49 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -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() {

감사 로그

- Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 - 추후 세션 연동 시 자동 채워집니다. + Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 + 세션 연동 시 자동 채워집니다.

- @@ -241,7 +246,7 @@ function AuditLogsPage() { PATH STATUS Action / Target - + @@ -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 || "", + ) } > @@ -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 || "") + } > @@ -388,12 +397,15 @@ function AuditLogsPage() { Request
- Request ID · {formatCellValue(details.request_id)} + Request ID ·{" "} + {formatCellValue(details.request_id)}
Event ID · {formatCellValue(row.event_id)}
-
IP · {formatCellValue(row.ip_address)}
+
+ IP · {formatCellValue(row.ip_address)} +
Latency ·{" "} {details.latency_ms !== undefined @@ -406,10 +418,15 @@ function AuditLogsPage() { Actor
- Actor ID · {row.user_id || details.actor_id || "-"} + Actor ID ·{" "} + {row.user_id || details.actor_id || "-"} +
+
+ Tenant · {formatCellValue(details.tenant_id)} +
+
+ Device · {formatCellValue(row.device_id)}
-
Tenant · {formatCellValue(details.tenant_id)}
-
Device · {formatCellValue(row.device_id)}
@@ -451,7 +468,6 @@ function AuditLogsPage() {
-
); diff --git a/adminfront/src/features/roles/RoleCreatePage.tsx b/adminfront/src/features/roles/RoleCreatePage.tsx new file mode 100644 index 00000000..20037871 --- /dev/null +++ b/adminfront/src/features/roles/RoleCreatePage.tsx @@ -0,0 +1,34 @@ +import { ChevronLeft } from "lucide-react"; +import { Link } from "react-router-dom"; +import { Button } from "../../components/ui/button"; + +function RoleCreatePage() { + return ( +
+
+ + + Back to list + +

새 역할 추가

+

+ 새로운 사용자 역할을 정의하고 권한을 할당합니다. +

+
+ +
+

+ 역할 생성 폼이 여기에 구현될 예정입니다. +

+ +
+
+ ); +} + +export default RoleCreatePage; diff --git a/adminfront/src/features/roles/RoleDetailPage.tsx b/adminfront/src/features/roles/RoleDetailPage.tsx new file mode 100644 index 00000000..404b98ba --- /dev/null +++ b/adminfront/src/features/roles/RoleDetailPage.tsx @@ -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 ( +
+
+ + + Back to list + +

역할 상세

+

역할 ID: {id}

+
+ +
+

+ 역할 상세 정보 및 권한 편집 화면이 여기에 구현될 예정입니다. +

+ +
+
+ ); +} + +export default RoleDetailPage; diff --git a/adminfront/src/features/roles/RoleListPage.tsx b/adminfront/src/features/roles/RoleListPage.tsx new file mode 100644 index 00000000..c89a5469 --- /dev/null +++ b/adminfront/src/features/roles/RoleListPage.tsx @@ -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 ( +
+
+
+
+ Roles + / + List +
+

역할 및 권한 (RBAC)

+

+ 사용자에게 부여할 역할과 해당 역할의 세부 권한을 관리합니다. +

+
+
+ + +
+
+ + + +
+ Role Management + + 총 {query.data?.total ?? 0}개 역할 정의됨 + +
+ RBAC +
+ + {(errorMsg || fallbackError) && ( +
+ {errorMsg ?? fallbackError} +
+ )} + + + + + ROLE NAME + DESCRIPTION + PERMISSIONS + ACTIONS + + + + {query.isLoading && ( + + 로딩 중... + + )} + {!query.isLoading && items.length === 0 && ( + + 정의된 역할이 없습니다. + + )} + {items.map((role) => ( + + +
+ + {role.name} +
+
+ {role.description} + +
+ {role.permissions.map((p) => ( + + {p} + + ))} +
+
+ +
+ + +
+
+
+ ))} +
+
+
+
+
+ ); +} + +export default RoleListPage; diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index ebdfd38d..35ff4845 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -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("/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( + "/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("/v1/admin/roles", { + params: { limit, offset }, + }); + return data; +} + +export async function deleteRole(roleId: string) { + await apiClient.delete(`/v1/admin/roles/${roleId}`); +}