forked from baron/baron-sso
Merge pull request 'dev/adminpage' (#125) from dev/adminpage into main
Reviewed-on: ai-team/baron-sso#125
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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";
|
||||
@@ -7,6 +9,9 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
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(
|
||||
[
|
||||
@@ -18,9 +23,14 @@ export const router = createBrowserRouter(
|
||||
{ path: "dashboard", element: <DashboardPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ 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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Moon,
|
||||
NotebookTabs,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
@@ -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: "Users", to: "/users", icon: Users },
|
||||
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
||||
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
||||
];
|
||||
|
||||
241
adminfront/src/features/api-keys/ApiKeyCreatePage.tsx
Normal file
241
adminfront/src/features/api-keys/ApiKeyCreatePage.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
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<string | null>(null);
|
||||
const [createdResult, setCreatedResult] = React.useState<ApiKeyCreateResponse | null>(null);
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<string[]>(["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 (
|
||||
<div className="max-w-xl mx-auto py-12 space-y-8">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
|
||||
<ShieldCheck size={32} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">API 키 생성 완료</h2>
|
||||
<p className="text-muted-foreground">
|
||||
아래의 비밀번호(Secret)는 보안을 위해 <span className="text-destructive font-bold">지금 한 번만</span> 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-2 border-primary/20 shadow-xl">
|
||||
<CardHeader className="bg-primary/5 border-b">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertCircle size={16} className="text-primary" />
|
||||
보안 시크릿 복사
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-8 pb-8 space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-bold text-muted-foreground uppercase tracking-widest">
|
||||
X-Baron-Key-Secret
|
||||
</Label>
|
||||
<div className="relative group">
|
||||
<Input
|
||||
readOnly
|
||||
value={createdResult.clientSecret}
|
||||
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
|
||||
onClick={() => handleCopy(createdResult.clientSecret)}
|
||||
>
|
||||
<Copy size={20} className="text-primary" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-center text-muted-foreground italic">
|
||||
복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex flex-col gap-2">
|
||||
<Button size="lg" className="w-full font-bold" asChild>
|
||||
<Link to="/api-keys">저장했습니다. 목록으로 이동</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-10">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Button variant="ghost" size="sm" className="-ml-3 text-muted-foreground" onClick={() => navigate("/api-keys")}>
|
||||
<ChevronLeft size={16} className="mr-1" />
|
||||
돌아가기
|
||||
</Button>
|
||||
<h2 className="text-3xl font-bold tracking-tight">새 API 키 생성</h2>
|
||||
<p className="text-muted-foreground">내부 시스템 연동을 위한 보안 인증 키를 구성합니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* 섹션 1: 이름 설정 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">1</span>
|
||||
<h3 className="font-semibold text-lg">키 이름 지정</h3>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium">서비스 또는 목적 식별 이름</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="예: Jenkins-CI, Grafana-Dashboard"
|
||||
className="text-base py-5"
|
||||
{...register("name", { required: "이름은 필수입니다." })}
|
||||
/>
|
||||
{errors.name && <p className="text-sm text-destructive mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* 섹션 2: 권한 선택 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">2</span>
|
||||
<h3 className="font-semibold text-lg">권한 범위(Scopes) 선택</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{AVAILABLE_SCOPES.map((scope) => {
|
||||
const isSelected = selectedScopes.includes(scope.id);
|
||||
return (
|
||||
<button
|
||||
key={scope.id}
|
||||
type="button"
|
||||
onClick={() => toggleScope(scope.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-md"
|
||||
: "border-border bg-card hover:border-muted-foreground/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className={cn("font-bold text-sm", isSelected ? "text-primary" : "")}>{scope.label}</span>
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-md flex items-center justify-center border",
|
||||
isSelected ? "bg-primary border-primary" : "border-muted-foreground/30"
|
||||
)}>
|
||||
{isSelected && <Check size={12} className="text-primary-foreground" />}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{scope.desc}
|
||||
</p>
|
||||
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">ID: {scope.id}</code>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 하단 실행 버튼 */}
|
||||
<div className="pt-6 flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-4 rounded-lg flex items-center gap-3">
|
||||
<AlertCircle size={20} />
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
|
||||
<div>
|
||||
<p className="text-sm font-bold">총 {selectedScopes.length}개의 권한이 할당됩니다.</p>
|
||||
<p className="text-xs text-muted-foreground">생성 즉시 활성화되어 사용 가능합니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
size="lg"
|
||||
className="px-8 font-bold shadow-lg shadow-primary/20"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
API 키 발급하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyCreatePage;
|
||||
172
adminfront/src/features/api-keys/ApiKeyListPage.tsx
Normal file
172
adminfront/src/features/api-keys/ApiKeyListPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
||||
|
||||
function ApiKeyListPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
||||
queryFn: () => fetchApiKeys(50, 0),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteApiKey(id),
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
!errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null;
|
||||
|
||||
const items = query.data?.items ?? [];
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>API Keys</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">List</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">API 키 관리 (M2M)</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고
|
||||
관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
API 키 생성
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>API Key Registry</CardTitle>
|
||||
<CardDescription>
|
||||
총 {query.data?.total ?? 0}개 API 키
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">System</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME</TableHead>
|
||||
<TableHead>CLIENT ID</TableHead>
|
||||
<TableHead>SCOPES</TableHead>
|
||||
<TableHead>LAST USED</TableHead>
|
||||
<TableHead className="text-right">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>등록된 API 키가 없습니다.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key size={14} className="text-[var(--color-muted)]" />
|
||||
{key.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code>{key.client_id}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.scopes.map((scope) => (
|
||||
<Badge
|
||||
key={scope}
|
||||
variant="muted"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsedAt
|
||||
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyListPage;
|
||||
@@ -559,4 +559,4 @@ function AuditLogsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default AuditLogsPage;
|
||||
export default AuditLogsPage;
|
||||
206
adminfront/src/features/users/UserCreatePage.tsx
Normal file
206
adminfront/src/features/users/UserCreatePage.tsx
Normal file
@@ -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<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<UserCreateRequest>({
|
||||
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 (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<Link to="/users" className="hover:underline">
|
||||
Users
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">New</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">사용자 추가</h2>
|
||||
</div>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/users">
|
||||
<ArrowLeft size={16} className="mr-2" />
|
||||
목록으로 돌아가기
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>계정 정보</CardTitle>
|
||||
<CardDescription>
|
||||
새로운 사용자를 시스템에 등록합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="user@example.com"
|
||||
{...register("email", { required: "이메일은 필수입니다." })}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
{...register("password", {
|
||||
required: "비밀번호는 필수입니다.",
|
||||
minLength: { value: 6, message: "6자 이상 입력해주세요." },
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
초기 비밀번호를 설정합니다.
|
||||
</p>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">이름</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="홍길동"
|
||||
{...register("name", { required: "이름은 필수입니다." })}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">전화번호</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="010-1234-5678"
|
||||
{...register("phone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyCode">회사 코드</Label>
|
||||
<Input
|
||||
id="companyCode"
|
||||
placeholder="HMAC"
|
||||
{...register("companyCode")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">부서</Label>
|
||||
<Input
|
||||
id="department"
|
||||
placeholder="개발팀"
|
||||
{...register("department")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">역할 (Role)</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="role"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("role")}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
시스템 접근 권한을 결정합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/users")}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
사용자 생성
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserCreatePage;
|
||||
251
adminfront/src/features/users/UserDetailPage.tsx
Normal file
251
adminfront/src/features/users/UserDetailPage.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
||||
|
||||
const { data: user, isLoading, isError } = useQuery({
|
||||
queryKey: ["user", id],
|
||||
queryFn: () => fetchUser(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<UserUpdateRequest>({
|
||||
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 <div className="p-8 text-center">Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError || !user) {
|
||||
return (
|
||||
<div className="p-8 text-center text-destructive">
|
||||
사용자를 찾을 수 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<Link to="/users" className="hover:underline">
|
||||
Users
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{user.name}</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">사용자 상세</h2>
|
||||
</div>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/users">
|
||||
<ArrowLeft size={16} className="mr-2" />
|
||||
목록으로 돌아가기
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>정보 수정</CardTitle>
|
||||
<CardDescription>
|
||||
{user.email} 계정의 정보를 수정합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-md bg-green-500/15 p-3 text-sm text-green-500">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">이름</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="홍길동"
|
||||
{...register("name", { required: "이름은 필수입니다." })}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">전화번호</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="010-1234-5678"
|
||||
{...register("phone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">상태</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="status"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("status")}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">역할 (Role)</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="role"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("role")}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyCode">회사 코드</Label>
|
||||
<Input
|
||||
id="companyCode"
|
||||
placeholder="HMAC"
|
||||
{...register("companyCode")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">부서</Label>
|
||||
<Input
|
||||
id="department"
|
||||
placeholder="개발팀"
|
||||
{...register("department")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="mb-4 text-sm font-medium text-muted-foreground">보안 설정</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호 변경</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="변경할 경우에만 입력"
|
||||
{...register("password")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/users")}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserDetailPage;
|
||||
270
adminfront/src/features/users/UserListPage.tsx
Normal file
270
adminfront/src/features/users/UserListPage.tsx
Normal file
@@ -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 (
|
||||
<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>Users</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">List</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">사용자 관리</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
시스템 사용자를 조회하고 관리합니다. (Local DB)
|
||||
</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="/users/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>User Registry</CardTitle>
|
||||
<CardDescription>
|
||||
총 {total}명의 사용자가 등록되어 있습니다.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="이름 또는 이메일 검색..."
|
||||
className="pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={handleSearch}>
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(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>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME / EMAIL</TableHead>
|
||||
<TableHead>ROLE</TableHead>
|
||||
<TableHead>STATUS</TableHead>
|
||||
<TableHead>COMPANY / DEPT</TableHead>
|
||||
<TableHead>CREATED</TableHead>
|
||||
<TableHead className="text-right">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
검색 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{user.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{user.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span>{user.companyCode || "-"}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1 || query.isFetching}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages || query.isFetching}
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserListPage;
|
||||
@@ -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,111 @@ export async function updateTenant(
|
||||
export async function deleteTenant(tenantId: string) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const { data } = await apiClient.get<ApiKeyListResponse>(
|
||||
"/v1/admin/api-keys",
|
||||
{
|
||||
params: { limit, offset },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createApiKey(payload: ApiKeyCreateRequest) {
|
||||
const { data } = await apiClient.post<ApiKeyCreateResponse>(
|
||||
"/v1/admin/api-keys",
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteApiKey(apiKeyId: string) {
|
||||
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
||||
}
|
||||
|
||||
// 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<UserListResponse>("/v1/admin/users", {
|
||||
params: { limit, offset, search },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchUser(userId: string) {
|
||||
const { data } = await apiClient.get<UserSummary>(
|
||||
`/v1/admin/users/${userId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createUser(payload: UserCreateRequest) {
|
||||
const { data } = await apiClient.post<UserSummary>(
|
||||
"/v1/admin/users",
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||
const { data } = await apiClient.put<UserSummary>(
|
||||
`/v1/admin/users/${userId}`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
await apiClient.delete(`/v1/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -186,6 +186,8 @@ func main() {
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
devHandler := handler.NewDevHandler()
|
||||
tenantHandler := handler.NewTenantHandler(db)
|
||||
userHandler := handler.NewUserHandler(db)
|
||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||
|
||||
// 3. Initialize Fiber
|
||||
appEnv := getEnv("APP_ENV", "dev")
|
||||
@@ -266,7 +268,10 @@ func main() {
|
||||
return err
|
||||
})
|
||||
|
||||
app.Use(recover.New())
|
||||
app.Use(recover.New(recover.Config{
|
||||
EnableStackTrace: true,
|
||||
}))
|
||||
|
||||
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000")
|
||||
allowCredentials := allowedOrigins != "*"
|
||||
app.Use(cors.New(cors.Config{
|
||||
@@ -448,6 +453,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("/stats", adminHandler.GetSystemStats)
|
||||
admin.Get("/tenants", tenantHandler.ListTenants)
|
||||
@@ -456,6 +462,18 @@ 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)
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -50,7 +51,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)
|
||||
|
||||
|
||||
30
backend/internal/domain/api_key.go
Normal file
30
backend/internal/domain/api_key.go
Normal file
@@ -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
|
||||
}
|
||||
145
backend/internal/handler/api_key_handler.go
Normal file
145
backend/internal/handler/api_key_handler.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
267
backend/internal/handler/user_handler.go
Normal file
267
backend/internal/handler/user_handler.go
Normal file
@@ -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),
|
||||
}
|
||||
}
|
||||
116
backend/internal/middleware/api_key_auth.go
Normal file
116
backend/internal/middleware/api_key_auth.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +22,14 @@ type AuditConfig struct {
|
||||
QueueSize int
|
||||
}
|
||||
|
||||
func isNil(i any) bool {
|
||||
if i == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(i)
|
||||
return v.Kind() == reflect.Ptr && v.IsNil()
|
||||
}
|
||||
|
||||
// AuditMiddleware provides comprehensive audit logging for all requests.
|
||||
// It enforces strict logging for state-changing commands (POST, PUT, DELETE, PATCH)
|
||||
// and best-effort logging for queries (GET, HEAD, OPTIONS).
|
||||
@@ -153,7 +162,7 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
||||
// 9. Store Log (Policy Enforcement)
|
||||
_, isWrite := writeMethods[c.Method()]
|
||||
|
||||
if config.Repo == nil {
|
||||
if isNil(config.Repo) {
|
||||
if isWrite {
|
||||
slog.Error("Audit repository missing for command", "req_id", reqID)
|
||||
return fiber.NewError(fiber.StatusServiceUnavailable, "Audit system unavailable")
|
||||
|
||||
@@ -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{
|
||||
|
||||
34
docs/kratos-integration-report.md
Normal file
34
docs/kratos-integration-report.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Ory Kratos 인증 엔진 전환 작업 보고서
|
||||
|
||||
## 1. 개요
|
||||
기존 Descope SaaS 기반 인증 시스템을 자가 호스팅(Self-hosted) IDP인 **Ory Kratos**로 전환하고, 이를 백엔드(Go Fiber)와 연동하는 작업을 수행하였습니다.
|
||||
|
||||
## 2. 주요 작업 내용
|
||||
|
||||
### 2.1 인프라 및 SDK 설정
|
||||
* **SDK 설치**: Ory Kratos Go SDK (`github.com/ory/kratos-client-go`)를 백엔드 프로젝트에 추가.
|
||||
* **클라이언트 초기화**: `AuthHandler` 내부에 Kratos Public API 통신을 위한 API Client 주입 및 환경 변수 연동.
|
||||
|
||||
### 2.2 인증 Flow 핸들러 구현 (`auth_handler.go`)
|
||||
Ory Kratos의 API-first 방식(Native Flow)에 맞춘 신규 핸들러 구현:
|
||||
* **InitializeLoginFlow**: 로그인 프로세스 시작을 위한 `flow_id` 발급 API.
|
||||
* **InitializeRegistrationFlow**: 회원가입 프로세스 시작을 위한 `flow_id` 발급 API.
|
||||
* **LoginSubmit**: 사용자의 ID/PW를 Kratos에 제출하고 성공 시 세션 쿠키를 클라이언트에 전달.
|
||||
* **RegistrationSubmit**: 커스텀 Traits(사용자 정보)와 비밀번호를 Kratos에 전달하여 계정 생성.
|
||||
|
||||
### 2.3 라우팅 설정 (`main.go`)
|
||||
신규 인증 엔진을 위한 전용 엔드포인트 그룹 등록:
|
||||
* `GET /api/v1/auth/ory/login/initialize`
|
||||
* `POST /api/v1/auth/ory/login/submit`
|
||||
* `GET /api/v1/auth/ory/registration/initialize`
|
||||
* `POST /api/v1/auth/ory/registration/submit`
|
||||
|
||||
### 2.4 보안 및 감사 (Security & Audit)
|
||||
* **세션 관리**: Kratos에서 발급한 `Set-Cookie` 헤더를 추출하여 클라이언트에 투명하게 전달(Pass-through).
|
||||
* **감사 로그**: 로그인 시도 및 성공 시 시각, IP, 대상 아이디 등을 ClickHouse 감사 로그 시스템에 기록.
|
||||
* **타입 오류 해결**: Kratos SDK의 구조체 타입 미스매치 이슈 해결(`result.Session` nil 비교 로직 수정).
|
||||
|
||||
## 3. 향후 과제 (Next Steps)
|
||||
1. **UI 연동**: `userfront` (Flutter)의 API 엔드포인트를 기존 Descope에서 신규 Ory 경로로 전환.
|
||||
2. **계정 복구**: 비밀번호 찾기(Recovery) 및 이메일 확인(Verification) Flow 추가 연동.
|
||||
3. **관리자 기능**: `adminfront`에서 Kratos Identities를 직접 조회/삭제하는 관리 API 연결.
|
||||
27
docs/kratos-todo-list.md
Normal file
27
docs/kratos-todo-list.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Kratos 기반 SSO 추가 기능 구현 로드맵
|
||||
|
||||
Ory Kratos의 표준 기능을 바탕으로 우리 프로젝트에 추가해야 할 핵심 기능 목록입니다.
|
||||
|
||||
## 1. 인증 수단 고도화
|
||||
- [ ] **소셜 로그인 연동**: Google, GitHub, Apple 등 주요 OIDC 제공자 연결.
|
||||
- [ ] **Passkeys (WebAuthn)**: 생체 인증을 통한 Passwordless 로그인 구현.
|
||||
- [ ] **MFA (Multi-Factor Authentication)**: TOTP(Authenticator 앱), Lookup Secret(복구 코드) 지원.
|
||||
|
||||
## 2. 사용자 셀프 서비스 (Self-Service)
|
||||
- [ ] **계정 복구 Flow**: 비밀번호 분실 시 이메일/SMS 링크를 통한 재설정 기능.
|
||||
- [ ] **계정 확인 (Verification)**: 가입 시 이메일/전화번호 점유 인증 절차.
|
||||
- [ ] **프로필 및 설정 화면**: 사용자가 직접 자신의 정보(Traits)와 비밀번호를 수정하는 화면.
|
||||
|
||||
## 3. 세션 보안 관리
|
||||
- [ ] **기기별 세션 관리**: 현재 로그인된 모든 브라우저/기기 목록 조회 및 특정 세션 강제 종료 기능.
|
||||
- [ ] **보안 로그 제공**: 사용자 본인의 최근 로그인 기록 및 보안 이벤트 확인 기능.
|
||||
|
||||
## 4. 관리자 기능 (Admin Operations)
|
||||
- [ ] **커스텀 아이덴티티 스키마**: 테넌트 요구사항에 맞춘 사용자 필드(부서, 직번 등) 동적 정의.
|
||||
- [ ] **사용자 일괄 마이그레이션**: 외부 데이터 대량 Import/Export API 및 도구.
|
||||
- [ ] **계정 상태 강제 제어**: 관리자에 의한 계정 잠금(Ban) 및 활성화 처리.
|
||||
|
||||
## 5. 시스템 연동 및 브랜딩
|
||||
- [ ] **메시지 템플릿 관리**: 이메일/SMS 발송 템플릿의 커스텀 HTML 에디터 및 미리보기.
|
||||
- [ ] **Webhook 이벤트 연동**: 가입/로그인 등 주요 이벤트 발생 시 외부 시스템으로 실시간 데이터 전송.
|
||||
- [ ] **멀티테넌시 브랜딩**: 접속 도메인이나 테넌트에 따른 로그인 화면 로고/컬러 동적 적용.
|
||||
84
docs/ory-stack-guide.md
Normal file
84
docs/ory-stack-guide.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Ory Stack 상세 가이드 (Baron SSO)
|
||||
|
||||
이 문서는 Baron SSO의 핵심 엔진인 Ory Stack의 구성 요소와 전체적인 인증/인가 플로우를 설명합니다.
|
||||
|
||||
## 1. 구성 요소별 상세 역할
|
||||
|
||||
|
||||
| 구성 요소 | 별칭 | 주요 역할 | 핵심 기능 |
|
||||
| :------------- | :------------ | :--------------- | :-------------------------------------------- |
|
||||
| **Kratos** | Identity | **사용자 관리** | 회원가입, 로그인, MFA, 프로필 수정, 계정 복구 |
|
||||
| | | | |
|
||||
| **Hydra** | OAuth2/OIDC | **연동 및 토큰** | Access/ID 토큰 발급, 외부 서비스 SSO 연동 |
|
||||
| **Keto** | Authorization | **권한 제어** | RBAC, ACL, "누가 무엇을 할 수 있는가" 판별 |
|
||||
| **Oathkeeper** | Proxy/Gateway | **접근 통제** | 요청 검증, 세션 확인, 헤더 변환, API 보호 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 시스템 플로우 (System Flow)
|
||||
|
||||
사용자가 보호된 백엔드 리소스에 접근할 때의 일반적인 흐름입니다.
|
||||
|
||||
### [인증 및 접근 흐름]
|
||||
|
||||
1. **Request**: 사용자가 API 요청을 보냄 (예: `GET /api/data`).
|
||||
2. **Intercept (Oathkeeper)**: Oathkeeper가 요청을 가로챔.
|
||||
3. **Authenticate (Kratos)**: Oathkeeper가 Kratos에게 사용자의 세션 쿠키가 유효한지 확인.
|
||||
4. **Authorize (Keto)**: Oathkeeper가 Keto에게 해당 사용자가 `/api/data`를 볼 권한이 있는지 확인.
|
||||
5. **Transform**: 모든 검증이 끝나면 Oathkeeper가 사용자 정보를 헤더(예: `X-User-ID`)에 담아 백엔드로 전달.
|
||||
6. **Response**: 백엔드가 로직을 수행하고 결과를 반환.
|
||||
|
||||
### [SSO 연동 흐름 (OIDC)]
|
||||
|
||||
1. **Discovery**: 외부 서비스(App A)가 로그인 필요 시 Hydra로 인증 요청을 보냄.
|
||||
2. **Login Challenge**: Hydra가 로그인 UI(`userfront`)로 리다이렉트하며 챌린지를 보냄.
|
||||
3. **Auth (Kratos)**: 사용자가 `userfront`에서 로그인(Kratos 사용).
|
||||
4. **Accept**: `userfront`가 로그인 성공 시 Hydra에게 챌린지 수락을 알림.
|
||||
5. **Token Issuance**: Hydra가 App A에게 Auth Code를 주고, App A는 이를 Access/ID Token으로 교환.
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처 다이어그램
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User((사용자))
|
||||
|
||||
subgraph "Edge / Gateway"
|
||||
OK[Ory Oathkeeper]
|
||||
end
|
||||
|
||||
subgraph "Identity & Access Layer"
|
||||
KR[Ory Kratos]
|
||||
HY[Ory Hydra]
|
||||
KE[Ory Keto]
|
||||
end
|
||||
|
||||
subgraph "Application Layer"
|
||||
BE[Backend API]
|
||||
AF[Admin Front]
|
||||
UF[User Front]
|
||||
end
|
||||
|
||||
User -->|API Request| OK
|
||||
User -->|Login/Register| UF
|
||||
UF --> KR
|
||||
|
||||
OK -->|1. 세션 확인| KR
|
||||
OK -->|2. 권한 확인| KE
|
||||
OK -->|3. 요청 전달| BE
|
||||
|
||||
AF -->|관리 작업| BE
|
||||
BE -->|Admin API 호출| KR & HY & KE
|
||||
|
||||
HY -->|SSO 토큰 발급| User
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 요약
|
||||
|
||||
- **Kratos**는 사용자의 정보를 알고 있습니다.
|
||||
- **Keto**는 사용자의 권한을 알고 있습니다.
|
||||
- **Hydra**는 사용자를 외부 서비스에 증명합니다.
|
||||
- **Oathkeeper**는 위 서비스들을 이용해 입구를 지킵니다.
|
||||
Reference in New Issue
Block a user