forked from baron/baron-sso
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
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"
|
|
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;
|