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 (
+
+
+
+
+
+ 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 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 로그를 조회합니다. 사용자/테넌트는 추후
+ 세션 연동 시 자동 채워집니다.
-
@@ -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 (
+
+
+
+
+
+ 역할 생성 폼이 여기에 구현될 예정입니다.
+
+
+ 추가하기 (준비 중)
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ 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}
+
+ ))}
+
+
+
+
+ navigate(`/roles/${role.id}`)}
+ >
+ 상세
+
+ handleDelete(role.id, role.name)}
+ disabled={deleteMutation.isPending}
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+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}`);
+}