From df037711218572283dc36a0049cebaa3ed6ad3ba Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 28 Jan 2026 17:13:53 +0900 Subject: [PATCH 1/4] =?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}`); +} From ee4c07f66d93407e17fa64056a0e4193dfc54992 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 29 Jan 2026 14:47:20 +0900 Subject: [PATCH 2/4] =?UTF-8?q?users=20=EC=A0=95=EB=B3=B4=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 6 + .../src/components/layout/AppLayout.tsx | 2 + .../src/features/users/UserCreatePage.tsx | 206 +++++++++++++ .../src/features/users/UserDetailPage.tsx | 251 ++++++++++++++++ .../src/features/users/UserListPage.tsx | 270 ++++++++++++++++++ adminfront/src/lib/adminApi.ts | 75 +++++ backend/cmd/server/main.go | 32 ++- backend/internal/bootstrap/bootstrap.go | 2 +- backend/internal/handler/audit_handler.go | 12 + backend/internal/handler/user_handler.go | 267 +++++++++++++++++ backend/internal/middleware/audit_required.go | 15 +- .../internal/repository/clickhouse_repo.go | 15 + 12 files changed, 1139 insertions(+), 14 deletions(-) create mode 100644 adminfront/src/features/users/UserCreatePage.tsx create mode 100644 adminfront/src/features/users/UserDetailPage.tsx create mode 100644 adminfront/src/features/users/UserListPage.tsx create mode 100644 backend/internal/handler/user_handler.go diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 165589ab..6100af70 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -12,6 +12,9 @@ import RoleListPage from "../features/roles/RoleListPage"; import TenantCreatePage from "../features/tenants/TenantCreatePage"; import TenantDetailPage from "../features/tenants/TenantDetailPage"; import TenantListPage from "../features/tenants/TenantListPage"; +import UserCreatePage from "../features/users/UserCreatePage"; +import UserDetailPage from "../features/users/UserDetailPage"; +import UserListPage from "../features/users/UserListPage"; export const router = createBrowserRouter( [ @@ -23,6 +26,9 @@ export const router = createBrowserRouter( { path: "dashboard", element: }, { path: "audit-logs", element: }, { path: "auth", element: }, + { path: "users", element: }, + { path: "users/new", element: }, + { path: "users/:id", element: }, { path: "tenants", element: }, { path: "tenants/new", element: }, { path: "tenants/:id", element: }, diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index e2feb40d..0c94d0ad 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -9,6 +9,7 @@ import { Shield, ShieldHalf, Sun, + Users, } from "lucide-react"; import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; @@ -17,6 +18,7 @@ const navItems = [ { label: "Overview", to: "/", icon: LayoutDashboard }, { label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf }, { label: "Tenants", to: "/tenants", icon: Building2 }, + { label: "Users", to: "/users", icon: Users }, { label: "Roles & RBAC", to: "/roles", icon: Shield }, { label: "API Keys (M2M)", to: "/api-keys", icon: Key }, { label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs }, diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx new file mode 100644 index 00000000..3fb5c9c4 --- /dev/null +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -0,0 +1,206 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ArrowLeft, Loader2, Save } from "lucide-react"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { Link, useNavigate } from "react-router-dom"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; +import { createUser, type UserCreateRequest } from "../../lib/adminApi"; + +function UserCreatePage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [error, setError] = React.useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + email: "", + password: "", + name: "", + phone: "", + role: "user", + companyCode: "", + department: "", + }, + }); + + const mutation = useMutation({ + mutationFn: createUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + navigate("/users"); + }, + onError: (err: AxiosError<{ error?: string }>) => { + setError(err.response?.data?.error || "사용자 생성에 실패했습니다."); + }, + }); + + const onSubmit = (data: UserCreateRequest) => { + setError(null); + mutation.mutate(data); + }; + + return ( +
+
+
+
+ + Users + + / + New +
+

사용자 추가

+
+ +
+ + + + 계정 정보 + + 새로운 사용자를 시스템에 등록합니다. + + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + +

+ 초기 비밀번호를 설정합니다. +

+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+

+ 시스템 접근 권한을 결정합니다. +

+
+ +
+ + +
+
+
+
+
+ ); +} + +export default UserCreatePage; \ No newline at end of file diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx new file mode 100644 index 00000000..afb4512a --- /dev/null +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -0,0 +1,251 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ArrowLeft, Loader2, Save } from "lucide-react"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; +import { fetchUser, updateUser, type UserUpdateRequest } from "../../lib/adminApi"; + +function UserDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [error, setError] = React.useState(null); + const [successMsg, setSuccessMsg] = React.useState(null); + + const { data: user, isLoading, isError } = useQuery({ + queryKey: ["user", id], + queryFn: () => fetchUser(id!), + enabled: !!id, + }); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + name: "", + phone: "", + role: "user", + status: "active", + companyCode: "", + department: "", + password: "", + }, + }); + + React.useEffect(() => { + if (user) { + reset({ + name: user.name, + phone: user.phone || "", + role: user.role, + status: user.status, + companyCode: user.companyCode || "", + department: user.department || "", + password: "", + }); + } + }, [user, reset]); + + const mutation = useMutation({ + mutationFn: (data: UserUpdateRequest) => updateUser(id!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["user", id] }); + setSuccessMsg("사용자 정보가 수정되었습니다."); + setError(null); + }, + onError: (err: AxiosError<{ error?: string }>) => { + setError(err.response?.data?.error || "사용자 수정에 실패했습니다."); + setSuccessMsg(null); + }, + }); + + const onSubmit = (data: UserUpdateRequest) => { + const payload = { ...data }; + if (!payload.password) { + delete payload.password; + } + mutation.mutate(payload); + }; + + if (isLoading) { + return
Loading...
; + } + + if (isError || !user) { + return ( +
+ 사용자를 찾을 수 없습니다. +
+ ); + } + + return ( +
+
+
+
+ + Users + + / + {user.name} +
+

사용자 상세

+
+ +
+ + + + 정보 수정 + + {user.email} 계정의 정보를 수정합니다. + + + +
+ {error && ( +
+ {error} +
+ )} + {successMsg && ( +
+ {successMsg} +
+ )} + +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+

보안 설정

+
+ + +

+ 비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다. +

+
+
+ +
+ + +
+
+
+
+
+ ); +} + +export default UserDetailPage; \ No newline at end of file diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx new file mode 100644 index 00000000..1eb631be --- /dev/null +++ b/adminfront/src/features/users/UserListPage.tsx @@ -0,0 +1,270 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { + ChevronLeft, + ChevronRight, + Pencil, + Plus, + RefreshCw, + Search, + Trash2, + User, +} from "lucide-react"; +import * as React from "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 { Input } from "../../components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table"; +import { deleteUser, fetchUsers } from "../../lib/adminApi"; + +function UserListPage() { + const navigate = useNavigate(); + const [page, setPage] = React.useState(1); + const [search, setSearch] = React.useState(""); + const [searchDraft, setSearchDraft] = React.useState(""); + const limit = 50; + const offset = (page - 1) * limit; + + const query = useQuery({ + queryKey: ["users", { limit, offset, search }], + queryFn: () => fetchUsers(limit, offset, search), + placeholderData: (previousData) => previousData, + }); + + const deleteMutation = useMutation({ + mutationFn: (userId: string) => deleteUser(userId), + onSuccess: () => { + query.refetch(); + }, + }); + + const handleSearch = () => { + setSearch(searchDraft); + setPage(1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(); + } + }; + + const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response + ?.data?.error; + const fallbackError = + !errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null; + + const items = query.data?.items ?? []; + const total = query.data?.total ?? 0; + const totalPages = Math.ceil(total / limit); + + const handleDelete = (userId: string, userName: string) => { + if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) { + return; + } + deleteMutation.mutate(userId); + }; + + return ( +
+
+
+
+ Users + / + List +
+

사용자 관리

+

+ 시스템 사용자를 조회하고 관리합니다. (Local DB) +

+
+
+ + +
+
+ + + +
+ User Registry + + 총 {total}명의 사용자가 등록되어 있습니다. + +
+
+ +
+
+ + setSearchDraft(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ +
+ + {(errorMsg || fallbackError) && ( +
+ {errorMsg ?? fallbackError} +
+ )} + +
+ + + + NAME / EMAIL + ROLE + STATUS + COMPANY / DEPT + CREATED + ACTIONS + + + + {query.isLoading && ( + + + 로딩 중... + + + )} + {!query.isLoading && items.length === 0 && ( + + + 검색 결과가 없습니다. + + + )} + {items.map((user) => ( + + +
+
+ +
+
+ {user.name} + + {user.email} + +
+
+
+ + {user.role} + + + + {user.status} + + + +
+ {user.companyCode || "-"} + + {user.department || "-"} + +
+
+ + {new Date(user.createdAt).toLocaleDateString()} + + +
+ + +
+
+
+ ))} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ Page {page} of {totalPages} +
+ +
+ )} +
+
+
+ ); +} + +export default UserListPage; diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 35ff4845..9ea88895 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -154,3 +154,78 @@ export async function fetchRoles(limit = 50, offset = 0) { export async function deleteRole(roleId: string) { await apiClient.delete(`/v1/admin/roles/${roleId}`); } + +// User Management +export type UserSummary = { + id: string; + email: string; + name: string; + phone?: string; + role: string; + status: string; + companyCode?: string; + department?: string; + createdAt: string; + updatedAt: string; +}; + +export type UserListResponse = { + items: UserSummary[]; + limit: number; + offset: number; + total: number; +}; + +export type UserCreateRequest = { + email: string; + password: string; + name: string; + phone?: string; + role?: string; + companyCode?: string; + department?: string; +}; + +export type UserUpdateRequest = { + password?: string; + name?: string; + phone?: string; + role?: string; + status?: string; + companyCode?: string; + department?: string; +}; + +export async function fetchUsers(limit = 50, offset = 0, search?: string) { + const { data } = await apiClient.get("/v1/admin/users", { + params: { limit, offset, search }, + }); + return data; +} + +export async function fetchUser(userId: string) { + const { data } = await apiClient.get( + `/v1/admin/users/${userId}`, + ); + return data; +} + +export async function createUser(payload: UserCreateRequest) { + const { data } = await apiClient.post( + "/v1/admin/users", + payload, + ); + return data; +} + +export async function updateUser(userId: string, payload: UserUpdateRequest) { + const { data } = await apiClient.put( + `/v1/admin/users/${userId}`, + payload, + ); + return data; +} + +export async function deleteUser(userId: string) { + await apiClient.delete(`/v1/admin/users/${userId}`); +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index db9dd4ee..c9f97421 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -104,13 +104,17 @@ func main() { // ClickHouse chHost := getEnv("CLICKHOUSE_HOST", "localhost") chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000")) - chUser := getEnv("CLICKHOUSE_USER", "default") - chPass := getEnv("CLICKHOUSE_PASSWORD", "") - chDB := getEnv("CLICKHOUSE_DB", "default") + chUser := getEnv("CLICKHOUSE_USER", "baron") + chPass := getEnv("CLICKHOUSE_PASSWORD", "password") + chDB := getEnv("CLICKHOUSE_DB", "baron_sso") - auditRepo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB) - if err != nil { + var auditRepo domain.AuditRepository + if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil { slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err) + auditRepo = nil // Explicitly set to nil interface + } else { + auditRepo = repo + slog.Info("✅ Connected to ClickHouse") } // PostgreSQL (Meta Store) @@ -138,11 +142,7 @@ func main() { }) if err != nil { slog.Error("❌ Failed to connect to PostgreSQL", "error", err) - // For local dev without Postgres, we might want to continue or panic. - // But bootstrap requires DB. - if getEnv("APP_ENV", "dev") == "production" { - os.Exit(1) - } + os.Exit(1) } else { slog.Info("✅ Connected to PostgreSQL") @@ -164,6 +164,7 @@ func main() { adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler() tenantHandler := handler.NewTenantHandler(db) + userHandler := handler.NewUserHandler(db) // 3. Initialize Fiber appEnv := getEnv("APP_ENV", "dev") @@ -244,7 +245,9 @@ func main() { return err }) - app.Use(recover.New()) + app.Use(recover.New(recover.Config{ + EnableStackTrace: true, + })) app.Use(cors.New(cors.Config{ AllowOrigins: "*", // Adjust in production AllowHeaders: "Origin, Content-Type, Accept, Authorization", @@ -389,6 +392,13 @@ func main() { admin.Put("/tenants/:id", tenantHandler.UpdateTenant) admin.Delete("/tenants/:id", tenantHandler.DeleteTenant) + // Admin User Management + admin.Get("/users", userHandler.ListUsers) + admin.Post("/users", userHandler.CreateUser) + admin.Get("/users/:id", userHandler.GetUser) + admin.Put("/users/:id", userHandler.UpdateUser) + admin.Delete("/users/:id", userHandler.DeleteUser) + // 개발자 포털 라우트 (RP/Consent 관리) dev := api.Group("/dev") dev.Get("/clients", devHandler.ListClients) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 45846451..8171d0db 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -50,7 +50,7 @@ func seedAdminUser(db *gorm.DB) error { } var user domain.User - if err := db.Where("email = ?", adminEmail).First(&user).Error; err != nil { + if err := db.Unscoped().Where("email = ?", adminEmail).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail) diff --git a/backend/internal/handler/audit_handler.go b/backend/internal/handler/audit_handler.go index 07c4e049..d246b352 100644 --- a/backend/internal/handler/audit_handler.go +++ b/backend/internal/handler/audit_handler.go @@ -42,6 +42,12 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error { req.EventID = ensureRequestID(c) } + if h.repo == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "Audit service unavailable", + }) + } + if err := h.repo.Create(&req); err != nil { // Log internal error but don't expose details return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ @@ -65,6 +71,12 @@ func (h *AuditHandler) ListLogs(c *fiber.Ctx) error { }) } + if h.repo == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "Audit service unavailable", + }) + } + logs, err := h.repo.FindPage(c.Context(), limit+1, cursor) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go new file mode 100644 index 00000000..8dee9917 --- /dev/null +++ b/backend/internal/handler/user_handler.go @@ -0,0 +1,267 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "errors" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type UserHandler struct { + DB *gorm.DB +} + +func NewUserHandler(db *gorm.DB) *UserHandler { + return &UserHandler{DB: db} +} + +type userSummary struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + Status string `json:"status"` + CompanyCode string `json:"companyCode"` + Department string `json:"department"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type userListResponse struct { + Items []userSummary `json:"items"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int64 `json:"total"` +} + +func (h *UserHandler) ListUsers(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + limit := c.QueryInt("limit", 50) + offset := c.QueryInt("offset", 0) + search := strings.TrimSpace(c.Query("search")) + + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + + query := h.DB.Model(&domain.User{}) + if search != "" { + like := "%" + search + "%" + query = query.Where("email ILIKE ? OR name ILIKE ?", like, like) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + var users []domain.User + if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + items := make([]userSummary, 0, len(users)) + for _, u := range users { + items = append(items, mapUserSummary(u)) + } + + return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) +} + +func (h *UserHandler) GetUser(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + userID := strings.TrimSpace(c.Params("id")) + if userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) + } + + var user domain.User + if err := h.DB.First(&user, "id = ?", userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(mapUserSummary(user)) +} + +func (h *UserHandler) CreateUser(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + var req struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + CompanyCode string `json:"companyCode"` + Department string `json:"department"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + email := strings.TrimSpace(req.Email) + if email == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"}) + } + password := req.Password + if password == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password is required"}) + } + name := strings.TrimSpace(req.Name) + if name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + } + + // Check duplicates + var exists domain.User + if err := h.DB.Where("email = ?", email).First(&exists).Error; err == nil { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"}) + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"}) + } + + user := domain.User{ + Email: email, + PasswordHash: string(hashedPassword), + Name: name, + Phone: req.Phone, + Role: req.Role, // default "user" handled by GORM if empty, but struct default usually works on zero value? GORM default tag works for zero value. + CompanyCode: req.CompanyCode, + Department: req.Department, + Status: "active", + AffiliationType: "internal", // Defaulting for now + } + + if user.Role == "" { + user.Role = "user" + } + + if err := h.DB.Create(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(mapUserSummary(user)) +} + +func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + userID := strings.TrimSpace(c.Params("id")) + if userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) + } + + var user domain.User + if err := h.DB.First(&user, "id = ?", userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + var req struct { + Password *string `json:"password"` + Name *string `json:"name"` + Phone *string `json:"phone"` + Role *string `json:"role"` + Status *string `json:"status"` + CompanyCode *string `json:"companyCode"` + Department *string `json:"department"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + if req.Name != nil { + user.Name = strings.TrimSpace(*req.Name) + } + if req.Phone != nil { + user.Phone = strings.TrimSpace(*req.Phone) + } + if req.Role != nil { + user.Role = strings.TrimSpace(*req.Role) + } + if req.Status != nil { + status := strings.ToLower(strings.TrimSpace(*req.Status)) + if status == "active" || status == "inactive" || status == "blocked" { + user.Status = status + } + } + if req.CompanyCode != nil { + user.CompanyCode = strings.TrimSpace(*req.CompanyCode) + } + if req.Department != nil { + user.Department = strings.TrimSpace(*req.Department) + } + + if req.Password != nil && *req.Password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"}) + } + user.PasswordHash = string(hashedPassword) + } + + if err := h.DB.Save(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(mapUserSummary(user)) +} + +func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + userID := strings.TrimSpace(c.Params("id")) + if userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) + } + + // Soft delete + if err := h.DB.Delete(&domain.User{}, "id = ?", userID).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +func mapUserSummary(u domain.User) userSummary { + return userSummary{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + Phone: u.Phone, + Role: u.Role, + Status: u.Status, + CompanyCode: u.CompanyCode, + Department: u.Department, + CreatedAt: u.CreatedAt.Format(time.RFC3339), + UpdatedAt: u.UpdatedAt.Format(time.RFC3339), + } +} diff --git a/backend/internal/middleware/audit_required.go b/backend/internal/middleware/audit_required.go index 5741df0f..1fe18280 100644 --- a/backend/internal/middleware/audit_required.go +++ b/backend/internal/middleware/audit_required.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "reflect" "time" "github.com/gofiber/fiber/v2" @@ -17,6 +18,14 @@ type AuditRequiredConfig struct { CommandMethods map[string]struct{} } +func isNil(i any) bool { + if i == nil { + return true + } + v := reflect.ValueOf(i) + return v.Kind() == reflect.Ptr && v.IsNil() +} + func RequireAudit(config AuditRequiredConfig) fiber.Handler { commandMethods := config.CommandMethods if len(commandMethods) == 0 { @@ -40,8 +49,10 @@ func RequireAudit(config AuditRequiredConfig) fiber.Handler { if _, excluded := excludePaths[c.Path()]; excluded { return c.Next() } - if config.Repo == nil { - return fiber.NewError(fiber.StatusServiceUnavailable, "audit repository unavailable") + + if isNil(config.Repo) { + slog.Warn("audit repository is nil, skipping audit log creation", "path", c.Path()) + return c.Next() // Don't block the request, just skip audit } start := time.Now() diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index c6c8723f..141a04e0 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -15,6 +15,21 @@ type ClickHouseRepository struct { } func NewClickHouseRepository(host string, port int, user, password, db string) (*ClickHouseRepository, error) { + // 1. Connect to 'default' database first to ensure target DB exists + tmpConn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{fmt.Sprintf("%s:%d", host, port)}, + Auth: clickhouse.Auth{ + Database: "default", + Username: user, + Password: password, + }, + }) + if err == nil { + _ = tmpConn.Exec(context.Background(), fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", db)) + _ = tmpConn.Close() + } + + // 2. Now connect to the target database conn, err := clickhouse.Open(&clickhouse.Options{ Addr: []string{fmt.Sprintf("%s:%d", host, port)}, Auth: clickhouse.Auth{ From a27026fa2a9ce480c45e04cc5b327730b3ccab56 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 29 Jan 2026 16:38:27 +0900 Subject: [PATCH 3/4] =?UTF-8?q?api=ED=82=A4=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 6 - .../src/components/layout/AppLayout.tsx | 2 - .../features/api-keys/ApiKeyCreatePage.tsx | 253 ++++++++++++++++-- adminfront/src/lib/adminApi.ts | 30 ++- backend/cmd/server/main.go | 7 + backend/internal/bootstrap/bootstrap.go | 1 + backend/internal/domain/api_key.go | 30 +++ backend/internal/handler/api_key_handler.go | 145 ++++++++++ backend/internal/middleware/api_key_auth.go | 116 ++++++++ 9 files changed, 546 insertions(+), 44 deletions(-) create mode 100644 backend/internal/domain/api_key.go create mode 100644 backend/internal/handler/api_key_handler.go create mode 100644 backend/internal/middleware/api_key_auth.go diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 6100af70..262320a1 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -6,9 +6,6 @@ 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"; @@ -34,9 +31,6 @@ export const router = createBrowserRouter( { 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 0c94d0ad..c7e651a7 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -6,7 +6,6 @@ import { LayoutDashboard, Moon, NotebookTabs, - Shield, ShieldHalf, Sun, Users, @@ -19,7 +18,6 @@ const navItems = [ { label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf }, { label: "Tenants", to: "/tenants", icon: Building2 }, { label: "Users", to: "/users", icon: Users }, - { 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 index 81a0c072..09280945 100644 --- a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx @@ -1,34 +1,241 @@ -import { ChevronLeft } from "lucide-react"; -import { Link } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } from "lucide-react"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +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 { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; +import { cn } from "../../lib/utils"; +import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi"; + +const AVAILABLE_SCOPES = [ + { id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." }, + { id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." }, + { id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." }, + { id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." }, + { id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." }, + { id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." }, +]; function ApiKeyCreatePage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [error, setError] = React.useState(null); + const [createdResult, setCreatedResult] = React.useState(null); + const [selectedScopes, setSelectedScopes] = React.useState(["audit:read", "user:read"]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm<{ name: string }>({ + defaultValues: { name: "" }, + }); + + const mutation = useMutation({ + mutationFn: (payload: ApiKeyCreateRequest) => createApiKey(payload), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["api-keys"] }); + setCreatedResult(data); + }, + onError: (err: AxiosError<{ error?: string }>) => { + setError(err.response?.data?.error || "API 키 생성에 실패했습니다."); + }, + }); + + const toggleScope = (scopeId: string) => { + setSelectedScopes((prev) => + prev.includes(scopeId) ? prev.filter((s) => s !== scopeId) : [...prev, scopeId] + ); + }; + + const onSubmit = (data: { name: string }) => { + if (selectedScopes.length === 0) { + setError("최소 하나 이상의 권한을 선택해야 합니다."); + return; + } + setError(null); + mutation.mutate({ name: data.name, scopes: selectedScopes }); + }; + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text); + }; + + if (createdResult) { + return ( +
+
+
+ +
+

API 키 생성 완료

+

+ 아래의 비밀번호(Secret)는 보안을 위해 지금 한 번만 표시됩니다. +

+
+ + + + + + 보안 시크릿 복사 + + + +
+ +
+ + +
+

+ 복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요. +

+
+ +
+ +
+
+
+
+ ); + } + return ( -
-
- - - Back to list - -

새 API 키 생성

-

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

+
+
+
+ +

새 API 키 생성

+

내부 시스템 연동을 위한 보안 인증 키를 구성합니다.

+
-
-

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

- +
+ {/* 섹션 1: 이름 설정 */} +
+
+ 1 +

키 이름 지정

+
+ + +
+ + + {errors.name &&

{errors.name.message}

} +
+
+
+
+ + {/* 섹션 2: 권한 선택 */} +
+
+ 2 +

권한 범위(Scopes) 선택

+
+
+ {AVAILABLE_SCOPES.map((scope) => { + const isSelected = selectedScopes.includes(scope.id); + return ( + + ); + })} +
+
+ + {/* 하단 실행 버튼 */} +
+ {error && ( +
+ +

{error}

+
+ )} + +
+
+

총 {selectedScopes.length}개의 권한이 할당됩니다.

+

생성 즉시 활성화되어 사용 가능합니다.

+
+ +
+
); } -export default ApiKeyCreatePage; +export default ApiKeyCreatePage; \ No newline at end of file diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 9ea88895..965d5092 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -127,8 +127,17 @@ export async function deleteTenant(tenantId: string) { } // API Key Management (M2M) +export type ApiKeyCreateRequest = { + name: string; + scopes: string[]; +}; + +export type ApiKeyCreateResponse = { + apiKey: ApiKeySummary; + clientSecret: string; +}; + export async function fetchApiKeys(limit = 50, offset = 0) { - // Placeholder implementation const { data } = await apiClient.get( "/v1/admin/api-keys", { @@ -138,21 +147,16 @@ export async function fetchApiKeys(limit = 50, offset = 0) { 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 }, - }); +export async function createApiKey(payload: ApiKeyCreateRequest) { + const { data } = await apiClient.post( + "/v1/admin/api-keys", + payload, + ); return data; } -export async function deleteRole(roleId: string) { - await apiClient.delete(`/v1/admin/roles/${roleId}`); +export async function deleteApiKey(apiKeyId: string) { + await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`); } // User Management diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c9f97421..999fadc1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -165,6 +165,7 @@ func main() { devHandler := handler.NewDevHandler() tenantHandler := handler.NewTenantHandler(db) userHandler := handler.NewUserHandler(db) + apiKeyHandler := handler.NewApiKeyHandler(db) // 3. Initialize Fiber appEnv := getEnv("APP_ENV", "dev") @@ -385,6 +386,7 @@ func main() { // Admin Routes admin := api.Group("/admin") + admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가 admin.Get("/check", adminHandler.CheckAuth) admin.Get("/tenants", tenantHandler.ListTenants) admin.Post("/tenants", tenantHandler.CreateTenant) @@ -399,6 +401,11 @@ func main() { admin.Put("/users/:id", userHandler.UpdateUser) admin.Delete("/users/:id", userHandler.DeleteUser) + // API Key Management (M2M) + admin.Get("/api-keys", apiKeyHandler.ListApiKeys) + admin.Post("/api-keys", apiKeyHandler.CreateApiKey) + admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey) + // 개발자 포털 라우트 (RP/Consent 관리) dev := api.Group("/dev") dev.Get("/clients", devHandler.ListClients) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 8171d0db..c6f6993a 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -35,6 +35,7 @@ func migrateSchemas(db *gorm.DB) error { return db.AutoMigrate( &domain.User{}, &domain.Tenant{}, + &domain.ApiKey{}, // &domain.RelyingParty{}, // TODO: Uncomment when model is ready // &domain.UserConsent{}, // TODO: Uncomment when model is ready ) diff --git a/backend/internal/domain/api_key.go b/backend/internal/domain/api_key.go new file mode 100644 index 00000000..dc4d86fa --- /dev/null +++ b/backend/internal/domain/api_key.go @@ -0,0 +1,30 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ApiKey represents an internal API key for Machine-to-Machine communication. +type ApiKey struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + ClientID string `gorm:"uniqueIndex;not null" json:"clientId"` + ClientSecretHash string `gorm:"not null" json:"-"` + Scopes string `json:"scopes"` // Space or comma separated + Status string `gorm:"default:'active'" json:"status"` + LastUsedAt *time.Time `json:"lastUsedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// BeforeCreate hook to generate UUID if not present. +func (k *ApiKey) BeforeCreate(tx *gorm.DB) (err error) { + if k.ID == "" { + k.ID = uuid.NewString() + } + return +} diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go new file mode 100644 index 00000000..bb1d8afb --- /dev/null +++ b/backend/internal/handler/api_key_handler.go @@ -0,0 +1,145 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type ApiKeyHandler struct { + DB *gorm.DB +} + +func NewApiKeyHandler(db *gorm.DB) *ApiKeyHandler { + return &ApiKeyHandler{DB: db} +} + +type apiKeySummary struct { + ID string `json:"id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + Scopes []string `json:"scopes"` + Status string `json:"status"` + LastUsedAt *string `json:"lastUsedAt"` + CreatedAt time.Time `json:"createdAt"` +} + +type apiKeyListResponse struct { + Items []apiKeySummary `json:"items"` + Total int64 `json:"total"` +} + +func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + limit := c.QueryInt("limit", 50) + offset := c.QueryInt("offset", 0) + + var total int64 + if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + var keys []domain.ApiKey + if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Find(&keys).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + items := make([]apiKeySummary, 0, len(keys)) + for _, k := range keys { + lastUsed := "" + if k.LastUsedAt != nil { + lastUsed = k.LastUsedAt.Format(time.RFC3339) + } + items = append(items, apiKeySummary{ + ID: k.ID, + Name: k.Name, + ClientID: k.ClientID, + Scopes: strings.Fields(strings.ReplaceAll(k.Scopes, ",", " ")), + Status: k.Status, + LastUsedAt: &lastUsed, + CreatedAt: k.CreatedAt, + }) + } + + return c.JSON(apiKeyListResponse{Items: items, Total: total}) +} + +func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + var req struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + if strings.TrimSpace(req.Name) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + } + + // Generate Client ID (16 chars hex) + clientID := GenerateSecureToken(8) + + // Generate plain secret (16 chars hex) + plainSecret := GenerateSecureToken(8) + + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash secret"}) + } + + apiKey := domain.ApiKey{ + Name: req.Name, + ClientID: clientID, + ClientSecretHash: string(hashedSecret), + Scopes: strings.Join(req.Scopes, " "), + Status: "active", + } + + if err := h.DB.Create(&apiKey).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + // Return summary + PLAIN SECRET (only this time) + lastUsed := "" + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "apiKey": apiKeySummary{ + ID: apiKey.ID, + Name: apiKey.Name, + ClientID: apiKey.ClientID, + Scopes: req.Scopes, + Status: apiKey.Status, + LastUsedAt: &lastUsed, + CreatedAt: apiKey.CreatedAt, + }, + "clientSecret": plainSecret, // VERY IMPORTANT: user must save this now + }) +} + +func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + id := c.Params("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id is required"}) + } + + if err := h.DB.Delete(&domain.ApiKey{}, "id = ?", id).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go new file mode 100644 index 00000000..a5aa8b86 --- /dev/null +++ b/backend/internal/middleware/api_key_auth.go @@ -0,0 +1,116 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "log/slog" + "time" + + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type ApiKeyAuthConfig struct { + DB *gorm.DB +} + +func ApiKeyAuth(config ApiKeyAuthConfig) fiber.Handler { + return func(c *fiber.Ctx) error { + // 1. 헤더에서 ID와 Secret 추출 + clientID := c.Get("X-Baron-Key-ID") + plainSecret := c.Get("X-Baron-Key-Secret") + + // 헤더가 둘 다 없으면 API Key 인증 시도가 아닌 것으로 간주하고 다음으로 넘김 (UI 세션 등을 위해) + if clientID == "" && plainSecret == "" { + return c.Next() + } + + if clientID == "" || plainSecret == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "API Key ID or Secret is missing", + }) + } + + // 2. DB에서 ClientID로 키 정보 조회 + var apiKey domain.ApiKey + if err := config.DB.Where("client_id = ? AND status = ?", clientID, "active").First(&apiKey).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid or inactive API Key", + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Database error during authentication", + }) + } + + // 3. Secret 해시 검증 + if err := bcrypt.CompareHashAndPassword([]byte(apiKey.ClientSecretHash), []byte(plainSecret)); err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid API Secret", + }) + } + + // 4. (비동기) 마지막 사용 시간 업데이트 + go func(id string) { + now := time.Now() + config.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("last_used_at", &now) + }(apiKey.ID) + + // 5. 컨텍스트에 권한 정보 저장 + c.Locals("apiKeyName", apiKey.Name) + c.Locals("apiScopes", apiKey.Scopes) + + // 6. Scope 기반 권한 검증 (RBAC) + if !validateScope(c.Method(), c.Path(), apiKey.Scopes) { + slog.Warn("API Key scope insufficient", "name", apiKey.Name, "method", c.Method(), "path", c.Path(), "has_scopes", apiKey.Scopes) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "Insufficient permissions (Scope mismatch)", + }) + } + + slog.Debug("API Key authenticated and authorized", "name", apiKey.Name, "path", c.Path()) + + return c.Next() + } +} + +// validateScope - 요청된 메서드와 경로가 허용된 Scopes에 포함되는지 검사합니다. +func validateScope(method, path string, rawScopes string) bool { + scopes := strings.Fields(rawScopes) + scopeMap := make(map[string]bool) + for _, s := range scopes { + scopeMap[s] = true + } + + // 1. 감사 로그 관련 (audit:*) + if strings.Contains(path, "/admin/audit") || strings.Contains(path, "/v1/audit") { + if method == fiber.MethodGet { + return scopeMap["audit:read"] + } + return scopeMap["audit:write"] + } + + // 2. 사용자 관리 관련 (user:*) + if strings.Contains(path, "/admin/users") { + if method == fiber.MethodGet { + return scopeMap["user:read"] + } + return scopeMap["user:write"] + } + + // 3. 테넌트 관리 관련 (tenant:*) + if strings.Contains(path, "/admin/tenants") { + if method == fiber.MethodGet { + return scopeMap["tenant:read"] + } + return scopeMap["tenant:write"] + } + + // 4. API 체크 등 공통 (기본 허용) + if strings.HasSuffix(path, "/admin/check") { + return true + } + + return false +} From 5bdcb672003aebf6d25c854e51450c7fe4e1aa07 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 29 Jan 2026 16:38:59 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EC=97=AD=ED=95=A0=EB=B6=80=EC=97=AC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/roles/RoleCreatePage.tsx | 34 ---- .../src/features/roles/RoleDetailPage.tsx | 34 ---- .../src/features/roles/RoleListPage.tsx | 172 ------------------ 3 files changed, 240 deletions(-) delete mode 100644 adminfront/src/features/roles/RoleCreatePage.tsx delete mode 100644 adminfront/src/features/roles/RoleDetailPage.tsx delete mode 100644 adminfront/src/features/roles/RoleListPage.tsx diff --git a/adminfront/src/features/roles/RoleCreatePage.tsx b/adminfront/src/features/roles/RoleCreatePage.tsx deleted file mode 100644 index 20037871..00000000 --- a/adminfront/src/features/roles/RoleCreatePage.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 404b98ba..00000000 --- a/adminfront/src/features/roles/RoleDetailPage.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index c89a5469..00000000 --- a/adminfront/src/features/roles/RoleListPage.tsx +++ /dev/null @@ -1,172 +0,0 @@ -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;