1
0
forked from baron/baron-sso

api키 생성 기능 및 페이지 구현

This commit is contained in:
2026-01-29 16:38:27 +09:00
parent ee4c07f66d
commit a27026fa2a
9 changed files with 546 additions and 44 deletions

View File

@@ -1,34 +1,241 @@
import { ChevronLeft } from "lucide-react";
import { Link } from "react-router-dom";
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 { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { cn } from "../../lib/utils";
import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi";
const AVAILABLE_SCOPES = [
{ id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." },
{ id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." },
{ id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." },
{ id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." },
{ id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." },
{ id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." },
];
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 || "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("최소 하나 이상의 권한을 선택해야 합니다.");
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">API </h2>
<p className="text-muted-foreground">
(Secret) <span className="text-destructive font-bold"> </span> .
</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" />
릿
</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">
( ) .
</p>
</div>
<div className="pt-4 flex flex-col gap-2">
<Button size="lg" className="w-full font-bold" asChild>
<Link to="/api-keys">. </Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-8">
<header className="space-y-2">
<Link
to="/api-keys"
className="flex items-center gap-1 text-sm text-[var(--color-muted)] hover:text-foreground"
>
<ChevronLeft size={14} />
Back to list
</Link>
<h2 className="text-3xl font-semibold"> API </h2>
<p className="text-sm text-[var(--color-muted)]">
Machine-to-Machine API .
</p>
<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" />
</Button>
<h2 className="text-3xl font-bold tracking-tight"> API </h2>
<p className="text-muted-foreground"> .</p>
</div>
</header>
<div className="rounded-lg border border-dashed p-12 text-center">
<p className="text-[var(--color-muted)]">
API .
</p>
<Button className="mt-4" disabled>
( )
</Button>
<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"> </h3>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium"> </Label>
<Input
id="name"
placeholder="예: Jenkins-CI, Grafana-Dashboard"
className="text-base py-5"
{...register("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"> (Scopes) </h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_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" : "")}>{scope.label}</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">
{scope.desc}
</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"> {selectedScopes.length} .</p>
<p className="text-xs text-muted-foreground"> .</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" />
)}
API
</Button>
</div>
</div>
</div>
</div>
);
}
export default ApiKeyCreatePage;
export default ApiKeyCreatePage;