forked from baron/baron-sso
351 lines
12 KiB
TypeScript
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;
|