첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
@@ -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"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
350
baron-sso/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx
Normal file
350
baron-sso/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
467
baron-sso/adminfront/src/features/api-keys/ApiKeyListPage.tsx
Normal file
467
baron-sso/adminfront/src/features/api-keys/ApiKeyListPage.tsx
Normal 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;
|
||||
59
baron-sso/adminfront/src/features/api-keys/apiKeyScopes.ts
Normal file
59
baron-sso/adminfront/src/features/api-keys/apiKeyScopes.ts
Normal 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을 조회합니다.",
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user