forked from baron/baron-sso
597 lines
22 KiB
TypeScript
597 lines
22 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import { Plus, Shield, Sparkles, Trash2, Upload } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { Link, useNavigate, useParams } 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 { Switch } from "../../components/ui/switch";
|
|
import { Textarea } from "../../components/ui/textarea";
|
|
import { createClient, fetchClient, updateClient } from "../../lib/devApi";
|
|
import type {
|
|
ClientStatus,
|
|
ClientType,
|
|
ClientUpsertRequest,
|
|
} from "../../lib/devApi";
|
|
import { t } from "../../lib/i18n";
|
|
import { cn } from "../../lib/utils";
|
|
|
|
interface ScopeItem {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
mandatory: boolean;
|
|
}
|
|
|
|
function ClientGeneralPage() {
|
|
const params = useParams();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const clientId = params.id;
|
|
const isCreate = !clientId;
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["client", clientId],
|
|
queryFn: () => fetchClient(clientId as string),
|
|
enabled: !isCreate,
|
|
});
|
|
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [logoUrl, setLogoUrl] = useState("");
|
|
const [clientType, setClientType] = useState<ClientType>("confidential");
|
|
const [status, setStatus] = useState<ClientStatus>("active");
|
|
const [redirectUris, setRedirectUris] = useState("");
|
|
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
|
{
|
|
id: "1",
|
|
name: "openid",
|
|
description: t("msg.dev.clients.scopes.openid", "OIDC 인증 필수 스코프"),
|
|
mandatory: true,
|
|
},
|
|
{
|
|
id: "2",
|
|
name: "profile",
|
|
description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"),
|
|
mandatory: false,
|
|
},
|
|
{
|
|
id: "3",
|
|
name: "email",
|
|
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
|
|
mandatory: false,
|
|
},
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!data) return;
|
|
const { client } = data;
|
|
setName(client.name || client.id);
|
|
setClientType(client.type);
|
|
setStatus(client.status);
|
|
|
|
const metadata = client.metadata ?? {};
|
|
if (typeof metadata.description === "string")
|
|
setDescription(metadata.description);
|
|
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
|
|
|
// Metadata에 저장된 구조화된 scope 정보가 있으면 사용, 없으면 기본 scopes 문자열에서 생성
|
|
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
|
if (savedScopes && Array.isArray(savedScopes)) {
|
|
setScopes(savedScopes);
|
|
} else {
|
|
setScopes(
|
|
client.scopes.map((s, idx) => ({
|
|
id: String(idx + 1),
|
|
name: s,
|
|
description: "",
|
|
mandatory: s === "openid",
|
|
})),
|
|
);
|
|
}
|
|
}, [data]);
|
|
|
|
const addScope = () => {
|
|
const newId = String(Date.now());
|
|
setScopes([
|
|
...scopes,
|
|
{ id: newId, name: "", description: "", mandatory: false },
|
|
]);
|
|
};
|
|
|
|
const updateScope = <K extends keyof ScopeItem>(
|
|
id: string,
|
|
field: K,
|
|
value: ScopeItem[K],
|
|
) => {
|
|
setScopes(
|
|
scopes.map((scope) =>
|
|
scope.id === id ? { ...scope, [field]: value } : scope,
|
|
),
|
|
);
|
|
};
|
|
|
|
const removeScope = (id: string) => {
|
|
setScopes(scopes.filter((s) => s.id !== id));
|
|
};
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async () => {
|
|
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
|
const payload: ClientUpsertRequest = {
|
|
name,
|
|
type: clientType,
|
|
status,
|
|
scopes: scopeNames,
|
|
metadata: {
|
|
description,
|
|
logo_url: logoUrl,
|
|
structured_scopes: scopes, // 향후 보존을 위해 metadata에 저장
|
|
},
|
|
};
|
|
|
|
// 생성 시에는 Redirect URIs를 포함해서 전송
|
|
if (isCreate) {
|
|
payload.redirectUris = redirectUris
|
|
.split(",")
|
|
.map((uri) => uri.trim())
|
|
.filter(Boolean);
|
|
return createClient(payload);
|
|
}
|
|
|
|
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음)
|
|
return updateClient(clientId as string, payload);
|
|
},
|
|
onSuccess: (result) => {
|
|
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
|
if (result?.client?.id) {
|
|
navigate(`/clients/${result.client.id}/settings`);
|
|
}
|
|
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
|
|
},
|
|
});
|
|
|
|
if (!isCreate && isLoading) {
|
|
return (
|
|
<div className="p-8 text-center">
|
|
{t("msg.dev.clients.general.loading", "Loading client...")}
|
|
</div>
|
|
);
|
|
}
|
|
if (!isCreate && (error || !data)) {
|
|
const errMsg =
|
|
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
|
(error as Error)?.message;
|
|
return (
|
|
<div className="p-8 text-center text-red-500">
|
|
{t(
|
|
"msg.dev.clients.general.load_error",
|
|
"Error loading client: {{error}}",
|
|
{
|
|
error: errMsg || t("msg.common.unknown_error", "unknown error"),
|
|
},
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const displayName = isCreate
|
|
? t("ui.dev.clients.general.display_new", "새 클라이언트")
|
|
: data?.client?.name || data?.client?.id;
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<header className="space-y-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
|
<Link to="/clients" className="text-primary hover:underline">
|
|
{t("ui.dev.clients.general.breadcrumb.section", "Applications")}
|
|
</Link>
|
|
<span>/</span>
|
|
<span className="text-foreground">{displayName}</span>
|
|
</div>
|
|
<h1 className="text-3xl font-black leading-tight">
|
|
{isCreate
|
|
? t("ui.dev.clients.general.title_create", "Create Client")
|
|
: t("ui.dev.clients.general.title_edit", "Client Settings")}
|
|
</h1>
|
|
</div>
|
|
<Badge
|
|
variant={status === "active" ? "success" : "muted"}
|
|
className="px-3 py-1 text-xs uppercase"
|
|
>
|
|
{status === "active"
|
|
? t("ui.common.status.active", "Active")
|
|
: t("ui.common.status.inactive", "Inactive")}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
|
{!isCreate && (
|
|
<>
|
|
<Link
|
|
to={`/clients/${clientId}`}
|
|
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
|
>
|
|
{t("ui.dev.clients.details.tab.connection", "Connection")}
|
|
</Link>
|
|
<Link
|
|
to={`/clients/${clientId}/consents`}
|
|
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
|
>
|
|
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
|
</Link>
|
|
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
|
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* 1. Application Identity */}
|
|
<div className="glass-panel p-6">
|
|
<CardTitle className="text-xl font-bold mb-2">
|
|
{t("ui.dev.clients.general.identity.title", "Application Identity")}
|
|
</CardTitle>
|
|
<CardDescription className="mb-6">
|
|
{t(
|
|
"msg.dev.clients.general.identity.subtitle",
|
|
"앱 이름과 설명, 로고를 설정합니다.",
|
|
)}
|
|
</CardDescription>
|
|
<div className="grid gap-8 md:grid-cols-2">
|
|
<div className="space-y-5">
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-semibold">
|
|
{t("ui.dev.clients.general.identity.name", "앱 이름")}{" "}
|
|
<span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t(
|
|
"ui.dev.clients.general.identity.name_placeholder",
|
|
"My Awesome Application",
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-semibold">
|
|
{t(
|
|
"ui.dev.clients.general.identity.description",
|
|
"Description",
|
|
)}
|
|
</Label>
|
|
<Textarea
|
|
rows={3}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t(
|
|
"ui.dev.clients.general.identity.description_placeholder",
|
|
"앱에 대한 간단한 설명을 입력하세요.",
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-semibold">
|
|
{t("ui.dev.clients.general.identity.logo", "App Logo URL")}
|
|
</Label>
|
|
<div className="flex gap-4">
|
|
<div className="flex-1 space-y-2">
|
|
<Input
|
|
value={logoUrl}
|
|
onChange={(e) => setLogoUrl(e.target.value)}
|
|
placeholder={t(
|
|
"ui.dev.clients.general.identity.logo_placeholder",
|
|
"https://example.com/logo.png",
|
|
)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.dev.clients.general.identity.logo_help",
|
|
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
|
|
{logoUrl ? (
|
|
<img
|
|
src={logoUrl}
|
|
alt={t(
|
|
"ui.dev.clients.general.identity.logo_preview",
|
|
"Logo Preview",
|
|
)}
|
|
className="h-full w-full object-contain"
|
|
/>
|
|
) : (
|
|
<Upload className="h-5 w-5 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2. Scopes (Moved up and upgraded) */}
|
|
<Card className="glass-panel">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
|
<div>
|
|
<CardTitle className="text-xl font-bold">
|
|
{t("ui.dev.clients.general.scopes.title", "Scopes")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.dev.clients.general.scopes.subtitle",
|
|
"이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.",
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addScope}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Create 모드일 때만 Redirect URIs 입력 필드 표시 */}
|
|
{isCreate && (
|
|
<div className="space-y-2 border-b border-border pb-6 mb-6">
|
|
<Label className="text-sm font-semibold">
|
|
{t("ui.dev.clients.general.redirect.label", "Redirect URIs")}{" "}
|
|
<span className="text-destructive">*</span>
|
|
</Label>
|
|
<Textarea
|
|
value={redirectUris}
|
|
onChange={(e) => setRedirectUris(e.target.value)}
|
|
placeholder={t(
|
|
"ui.dev.clients.general.redirect.placeholder",
|
|
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
|
|
)}
|
|
className="font-mono text-sm"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.dev.clients.general.redirect.help",
|
|
"인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="rounded-md border border-border overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-bold">Scope Name</th>
|
|
<th className="px-4 py-3 text-left font-bold">
|
|
{t(
|
|
"ui.dev.clients.general.scopes.table.name",
|
|
"Scope Name",
|
|
)}
|
|
</th>
|
|
<th className="px-4 py-3 text-left font-bold">
|
|
{t(
|
|
"ui.dev.clients.general.scopes.table.description",
|
|
"Description",
|
|
)}
|
|
</th>
|
|
<th className="px-4 py-3 text-center font-bold">
|
|
{t(
|
|
"ui.dev.clients.general.scopes.table.mandatory",
|
|
"Mandatory",
|
|
)}
|
|
</th>
|
|
<th className="px-4 py-3 text-right" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{scopes.map((s) => (
|
|
<tr
|
|
key={s.id}
|
|
className="hover:bg-muted/30 transition-colors"
|
|
>
|
|
<td className="px-4 py-3">
|
|
<Input
|
|
value={s.name}
|
|
onChange={(e) =>
|
|
updateScope(s.id, "name", e.target.value)
|
|
}
|
|
className="h-8 font-mono text-xs"
|
|
placeholder={t(
|
|
"ui.dev.clients.general.scopes.name_placeholder",
|
|
"e.g. profile",
|
|
)}
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Input
|
|
value={s.description}
|
|
onChange={(e) =>
|
|
updateScope(s.id, "description", e.target.value)
|
|
}
|
|
className="h-8 text-xs"
|
|
placeholder={t(
|
|
"ui.dev.clients.general.scopes.description_placeholder",
|
|
"권한에 대한 설명",
|
|
)}
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<div className="flex justify-center">
|
|
<Switch
|
|
checked={s.mandatory}
|
|
onCheckedChange={(checked) =>
|
|
updateScope(s.id, "mandatory", checked)
|
|
}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeScope(s.id)}
|
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{scopes.length === 0 && (
|
|
<tr>
|
|
<td
|
|
colSpan={4}
|
|
className="px-4 py-8 text-center text-muted-foreground"
|
|
>
|
|
{t(
|
|
"msg.dev.clients.general.scopes.empty",
|
|
"등록된 스코프가 없습니다.",
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 3. Security Settings (Moved down) */}
|
|
<Card className="glass-panel">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-xl font-bold">
|
|
{t("ui.dev.clients.general.security.title", "보안 설정")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.dev.clients.general.security.subtitle",
|
|
"클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<label
|
|
className={cn(
|
|
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
|
clientType === "confidential"
|
|
? "border-primary bg-primary/5"
|
|
: "border-border bg-card hover:border-muted-foreground/40",
|
|
)}
|
|
>
|
|
<input
|
|
className="sr-only"
|
|
type="radio"
|
|
name="client-type"
|
|
checked={clientType === "confidential"}
|
|
onChange={() => setClientType("confidential")}
|
|
/>
|
|
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
|
<Shield className="h-4 w-4 text-primary" />
|
|
{t(
|
|
"ui.dev.clients.general.security.confidential",
|
|
"Confidential",
|
|
)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.dev.clients.general.security.confidential_help",
|
|
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
|
|
)}
|
|
</span>
|
|
<span className="absolute right-4 top-4 text-primary">
|
|
{clientType === "confidential" ? "✓" : ""}
|
|
</span>
|
|
</label>
|
|
|
|
<label
|
|
className={cn(
|
|
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
|
clientType === "public"
|
|
? "border-primary bg-primary/5"
|
|
: "border-border bg-card hover:border-muted-foreground/40",
|
|
)}
|
|
>
|
|
<input
|
|
className="sr-only"
|
|
type="radio"
|
|
name="client-type"
|
|
checked={clientType === "public"}
|
|
onChange={() => setClientType("public")}
|
|
/>
|
|
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
|
<Sparkles className="h-4 w-4" />
|
|
{t("ui.dev.clients.general.security.public", "Public")}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.dev.clients.general.security.public_help",
|
|
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
|
|
)}
|
|
</span>
|
|
<span className="absolute right-4 top-4 text-primary">
|
|
{clientType === "public" ? "✓" : ""}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex items-center justify-end gap-3 border-t border-border pt-4">
|
|
<Button variant="outline" onClick={() => navigate("/clients")}>
|
|
{t("ui.common.cancel", "취소")}
|
|
</Button>
|
|
<Button
|
|
onClick={() => mutation.mutate()}
|
|
disabled={mutation.isPending}
|
|
className="px-8 shadow-lg shadow-primary/20"
|
|
>
|
|
{mutation.isPending
|
|
? t("msg.common.saving", "저장 중...")
|
|
: isCreate
|
|
? t("ui.dev.clients.general.create", "클라이언트 생성")
|
|
: t("ui.dev.clients.general.save", "설정 저장")}
|
|
</Button>
|
|
</div>
|
|
|
|
{!isCreate && (
|
|
<div className="glass-panel flex flex-wrap gap-x-12 gap-y-4 p-4 opacity-70">
|
|
<div className="space-y-1">
|
|
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
|
{t("ui.dev.clients.general.footer.client_id", "Client ID")}
|
|
</span>
|
|
<span className="font-mono text-sm block">{data?.client?.id}</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
|
{t("ui.dev.clients.general.footer.created_on", "Created On")}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground block">
|
|
{data?.client?.createdAt
|
|
? new Date(data.client.createdAt).toLocaleString()
|
|
: "-"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ClientGeneralPage;
|