1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/api-keys/ApiKeyListPage.tsx
chan 349cdf5fcd feat(org): 조직도 렌더링 및 동기화 로직 대폭 개선, 역할(Role) 강제화, 탭 정리
- 조직도 렌더링 시 너비 동적 계산 및 스크롤 문제 해결
- 하위 조직(Leaf)을 부모 박스 내부에 임베딩하여 2열로 깔끔하게 표시되도록 조직도 UI 전면 개편
- 사용자 생성/수정 및 CSV 업로드 시 직급(Position)과 직무(JobTitle)가 정상적으로 Kratos 및 로컬 DB에 동기화되도록 백엔드 API 수정
- CSV 조직도 업로드 시 계층 구분을 '/' 대신 ' > '로 변경하여 이름에 '/'가 포함된 부서(예: 평면/셀)가 분리되지 않도록 보호
- 잘못 입력된 과거 직책 데이터(팀장, 그룹장 등)를 'user' 권한으로 일괄 초기화하고, 이후 'role' 필드에 시스템 권한(user, tenant_admin, super_admin) 외의 값이 들어오지 않도록 백엔드 정규화 로직 강화
- 사용자 목록 페이지의 페이지네이션 제한을 50명에서 1000명으로 상향 조정
- 테넌트 목록 페이지에 이름/슬러그 기반 검색 기능 추가
- 관리자 UI 전반에서 불필요한 배지(Admin only, System 등) 제거 및 테넌트 상세 페이지의 미사용 '외부 연동' 탭 삭제
2026-04-10 13:48:12 +09:00

215 lines
7.5 KiB
TypeScript

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";
import { t } from "../../lib/i18n";
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
? 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);
};
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t(
"msg.admin.api_keys.list.subtitle",
"서버 간 통신(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} />
{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>
</div>
</header>
<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>
{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="sticky top-0 z-10 bg-secondary shadow-sm">
<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">
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default ApiKeyListPage;