1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx

351 lines
12 KiB
TypeScript

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;