첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApiKey } from "../../lib/adminApi";
import ApiKeyCreatePage from "./ApiKeyCreatePage";
vi.mock("../../lib/adminApi", () => ({
createApiKey: vi.fn(async () => ({
apiKey: {
id: "api-key-id",
name: "org-context-client",
client_id: "client-id",
scopes: ["audit:read", "user:read", "org-context:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
clientSecret: "secret",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ApiKeyCreatePage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("ApiKeyCreatePage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders org-context:read as a selectable API key scope", () => {
renderPage();
expect(screen.getByText("조직 Context 조회")).toBeInTheDocument();
expect(screen.getByText("ID: org-context:read")).toBeInTheDocument();
});
it("includes org-context:read in the create request when selected", async () => {
const user = userEvent.setup();
renderPage();
await user.type(
screen.getByLabelText("서비스 또는 목적 식별 이름"),
"org-context-client",
);
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
await user.click(screen.getByRole("button", { name: /API 키 발급하기/ }));
await waitFor(() => {
expect(createApiKey).toHaveBeenCalledWith(
expect.objectContaining({
name: "org-context-client",
scopes: expect.arrayContaining(["org-context:read"]),
}),
);
});
});
});

View File

@@ -0,0 +1,350 @@
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 { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
type ApiKeyCreateRequest,
type ApiKeyCreateResponse,
createApiKey,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
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 ||
t("msg.admin.api_keys.create.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(
t(
"msg.admin.api_keys.create.scope_required",
"최소 하나 이상의 권한을 선택해야 합니다.",
),
);
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">
{t("ui.admin.api_keys.create.success.title", "API 키 생성 완료")}
</h2>
<p className="text-muted-foreground">
{t(
"msg.admin.api_keys.create.success.notice",
"아래의 비밀번호(Secret)는 보안을 위해 ",
)}
<span className="text-destructive font-bold">
{t(
"msg.admin.api_keys.create.success.notice_emphasis",
"지금 한 번만",
)}
</span>{" "}
{t(
"msg.admin.api_keys.create.success.notice_suffix",
"표시됩니다.",
)}
</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" />
{t(
"ui.admin.api_keys.create.success.copy_secret",
"보안 시크릿 복사",
)}
</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">
{t(
"msg.admin.api_keys.create.success.copy_hint",
"복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
)}
</p>
</div>
<div className="pt-4 flex flex-col gap-2">
<Button size="lg" className="w-full font-bold" asChild>
<Link to="/api-keys">
{t(
"ui.admin.api_keys.create.success.go_list",
"저장했습니다. 목록으로 이동",
)}
</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" />
{t("ui.common.back", "돌아가기")}
</Button>
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.api_keys.create.title", "새 API 키 생성")}
</h2>
<p className="text-muted-foreground">
{t(
"msg.admin.api_keys.create.subtitle",
"내부 시스템 연동을 위한 보안 인증 키를 구성합니다.",
)}
</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">
{t("ui.admin.api_keys.create.section_name", "키 이름 지정")}
</h3>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">
{t(
"ui.admin.api_keys.create.name_label",
"서비스 또는 목적 식별 이름",
)}
</Label>
<Input
id="name"
placeholder={t(
"ui.admin.api_keys.create.name_placeholder",
"예: Jenkins-CI, Grafana-Dashboard",
)}
className="text-base py-5"
{...register("name", {
required: t(
"msg.admin.api_keys.create.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">
{t(
"ui.admin.api_keys.create.section_scopes",
"권한 범위(Scopes) 선택",
)}
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_API_KEY_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" : "",
)}
>
{t(scope.labelKey, scope.labelFallback)}
</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">
{t(scope.descKey, scope.descFallback)}
</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">
{t(
"msg.admin.api_keys.create.scopes_count",
"총 {{count}}개의 권한이 할당됩니다.",
{ count: selectedScopes.length },
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.api_keys.create.scopes_hint",
"생성 즉시 활성화되어 사용 가능합니다.",
)}
</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" />
)}
{t("ui.admin.api_keys.create.submit", "API 키 발급하기")}
</Button>
</div>
</div>
</div>
</div>
);
}
export default ApiKeyCreatePage;

View File

@@ -0,0 +1,125 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchApiKeys,
rotateApiKeySecret,
updateApiKeyScopes,
} from "../../lib/adminApi";
import ApiKeyListPage from "./ApiKeyListPage";
vi.mock("../../lib/i18n", () => ({
t: (_key: string, fallback?: string) => fallback ?? "",
}));
vi.mock("../../lib/adminApi", () => ({
fetchApiKeys: vi.fn(async () => ({
items: [
{
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
],
total: 1,
})),
deleteApiKey: vi.fn(async () => undefined),
updateApiKeyScopes: vi.fn(async () => ({
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read", "org-context:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
})),
rotateApiKeySecret: vi.fn(async () => ({
apiKey: {
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
clientSecret: "rotated-secret",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ApiKeyListPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("ApiKeyListPage", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("updates scopes without changing client_id", async () => {
const user = userEvent.setup();
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /권한 수정/ }));
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
await user.click(screen.getByRole("button", { name: /권한 저장/ }));
await waitFor(() => {
expect(updateApiKeyScopes).toHaveBeenCalledWith("api-key-id", {
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
});
});
});
it("rotates only the secret and shows the one-time secret", async () => {
const user = userEvent.setup();
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Secret 재발급/ }));
await waitFor(() => {
expect(rotateApiKeySecret).toHaveBeenCalledWith("api-key-id");
});
expect(
await screen.findByDisplayValue("rotated-secret"),
).toBeInTheDocument();
expect(fetchApiKeys).toHaveBeenCalled();
});
it("refresh button refetches the list without navigation", async () => {
const user = userEvent.setup();
renderPage();
await screen.findByText("client-id-stable");
const refreshButton = screen.getByRole("button", { name: /새로고침/ });
expect(refreshButton).toHaveAttribute("type", "button");
await user.click(refreshButton);
await waitFor(() => {
expect(fetchApiKeys).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,467 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Copy,
Edit3,
Key,
Plus,
RefreshCw,
RotateCcw,
Save,
Trash2,
} from "lucide-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import { commonStickyTableHeaderClass } from "../../../../common/ui/table";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import {
type ApiKeySummary,
deleteApiKey,
fetchApiKeys,
rotateApiKeySecret,
updateApiKeyScopes,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
function ApiKeyListPage() {
const [editingKey, setEditingKey] = React.useState<ApiKeySummary | null>(
null,
);
const [draftScopes, setDraftScopes] = React.useState<string[]>([]);
const [rotatedSecret, setRotatedSecret] = React.useState<{
key: ApiKeySummary;
clientSecret: string;
} | null>(null);
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 updateScopesMutation = useMutation({
mutationFn: ({ id, scopes }: { id: string; scopes: string[] }) =>
updateApiKeyScopes(id, { scopes }),
onSuccess: () => {
setEditingKey(null);
setDraftScopes([]);
query.refetch();
},
});
const rotateSecretMutation = useMutation({
mutationFn: (id: string) => rotateApiKeySecret(id),
onSuccess: (data) => {
setRotatedSecret({
key: data.apiKey,
clientSecret: data.clientSecret,
});
query.refetch();
},
});
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError
? t(
"msg.admin.api_keys.list.fetch_error",
"API 키 목록 조회에 실패했습니다.",
)
: null;
const items = query.data?.items ?? [];
const handleDelete = (id: string, name: string) => {
if (
!window.confirm(
t(
"msg.admin.api_keys.list.delete_confirm",
'API 키 "{{name}}"를 삭제할까요?',
{ name },
),
)
) {
return;
}
deleteMutation.mutate(id);
};
const openScopeEditor = (key: ApiKeySummary) => {
setEditingKey(key);
setDraftScopes(key.scopes);
};
const toggleDraftScope = (scopeId: string) => {
setDraftScopes((current) =>
current.includes(scopeId)
? current.filter((scope) => scope !== scopeId)
: [...current, scopeId],
);
};
const saveScopes = () => {
if (!editingKey || draftScopes.length === 0) return;
updateScopesMutation.mutate({ id: editingKey.id, scopes: draftScopes });
};
const handleRotateSecret = (key: ApiKeySummary) => {
if (
!window.confirm(
t(
"msg.admin.api_keys.list.rotate_confirm",
'API 키 "{{name}}"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다.',
{ name: key.name },
),
)
) {
return;
}
rotateSecretMutation.mutate(key.id);
};
const copyRotatedSecret = () => {
if (!rotatedSecret) return;
navigator.clipboard.writeText(rotatedSecret.clientSecret);
};
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<PageHeader
sticky
titleAs="h2"
icon={<Key size={20} />}
title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
description={t(
"msg.admin.api_keys.list.subtitle",
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
)}
actions={
<>
<Button
type="button"
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/api-keys/new">
<Plus size={16} />
{t("ui.admin.api_keys.list.add", "API 키 생성")}
</Link>
</Button>
</>
}
/>
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.apikeys.registry.count",
"총 {{count}}개의 활성 키가 등록되어 있습니다.",
{ count: query.data?.items?.length ?? 0 },
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError}
</div>
)}
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.api_keys.list.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
{t(
"msg.admin.api_keys.list.empty",
"등록된 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")
: t("ui.common.never", "Never")}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openScopeEditor(key)}
>
<Edit3 size={14} />
{t(
"ui.admin.api_keys.list.edit_scopes",
"권한 수정",
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleRotateSecret(key)}
disabled={rotateSecretMutation.isPending}
>
<RotateCcw size={14} />
{t(
"ui.admin.api_keys.list.rotate_secret",
"Secret 재발급",
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
<Dialog
open={editingKey !== null}
onOpenChange={() => setEditingKey(null)}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.api_keys.list.edit_scopes", "권한 수정")}
</DialogTitle>
<DialogDescription>
{editingKey
? t(
"msg.admin.api_keys.list.edit_scopes_desc",
"{{clientId}}의 CLIENT_ID는 유지하고 권한만 변경합니다.",
{ clientId: editingKey.client_id },
)
: null}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
const isSelected = draftScopes.includes(scope.id);
return (
<button
key={scope.id}
type="button"
onClick={() => toggleDraftScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 rounded-lg border-2 p-4 text-left transition-all",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/30",
)}
>
<span className="font-bold text-sm">
{t(scope.labelKey, scope.labelFallback)}
</span>
<span className="text-[11px] text-muted-foreground leading-snug">
{t(scope.descKey, scope.descFallback)}
</span>
<code className="text-[9px] font-mono opacity-60 uppercase tracking-tighter">
ID: {scope.id}
</code>
</button>
);
})}
</div>
{draftScopes.length === 0 && (
<p className="text-sm text-destructive">
{t(
"msg.admin.api_keys.create.scope_required",
"최소 하나 이상의 권한을 선택해야 합니다.",
)}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingKey(null)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={saveScopes}
disabled={
updateScopesMutation.isPending || draftScopes.length === 0
}
>
<Save size={16} />
{t("ui.admin.api_keys.list.save_scopes", "권한 저장")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={rotatedSecret !== null}
onOpenChange={() => setRotatedSecret(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t(
"ui.admin.api_keys.list.rotate_secret_done",
"Secret 재발급 완료",
)}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.api_keys.list.rotate_secret_notice",
"새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다.",
)}
</DialogDescription>
</DialogHeader>
{rotatedSecret && (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
CLIENT ID
</p>
<code className="block rounded-md bg-muted px-3 py-2 text-sm">
{rotatedSecret.key.client_id}
</code>
</div>
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
X-Baron-Key-Secret
</p>
<div className="relative">
<Input
readOnly
value={rotatedSecret.clientSecret}
className="font-mono pr-12"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={copyRotatedSecret}
>
<Copy size={16} />
</Button>
</div>
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => setRotatedSecret(null)}>
{t("ui.common.confirm", "확인")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default ApiKeyListPage;

View File

@@ -0,0 +1,59 @@
export type ApiKeyScopeOption = {
id: string;
labelKey: string;
labelFallback: string;
descKey: string;
descFallback: string;
};
export const AVAILABLE_API_KEY_SCOPES: ApiKeyScopeOption[] = [
{
id: "audit:read",
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
labelFallback: "감사 로그 조회",
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
},
{
id: "audit:write",
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
labelFallback: "감사 로그 생성",
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
},
{
id: "user:read",
labelKey: "ui.admin.api_keys.scopes.user_read.title",
labelFallback: "사용자 조회",
descKey: "msg.admin.api_keys.scopes.user_read.desc",
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
},
{
id: "user:write",
labelKey: "ui.admin.api_keys.scopes.user_write.title",
labelFallback: "사용자 관리",
descKey: "msg.admin.api_keys.scopes.user_write.desc",
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
},
{
id: "tenant:read",
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
labelFallback: "테넌트 조회",
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
descFallback: "등록된 모든 조직 정보를 조회합니다.",
},
{
id: "tenant:write",
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
labelFallback: "테넌트 관리",
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
descFallback: "테넌트 정보를 직접 제어합니다.",
},
{
id: "org-context:read",
labelKey: "ui.admin.api_keys.scopes.org_context_read.title",
labelFallback: "조직 Context 조회",
descKey: "msg.admin.api_keys.scopes.org_context_read.desc",
descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.",
},
];