1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/clients/ClientGeneralPage.tsx

1533 lines
59 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowLeft,
Info,
Plus,
Save,
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 { toast } from "../../components/ui/use-toast";
import {
createClient,
deleteClient,
fetchClient,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
updateClient,
updateClientStatus,
} 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;
}
type SecurityProfile = "private" | "pkce";
type TokenEndpointAuthMethod =
| "none"
| "client_secret_basic"
| "private_key_jwt";
const HEADLESS_LOGIN_ALLOWED_ALGORITHMS = [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"EdDSA",
] as const;
const HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET = new Set<string>(
HEADLESS_LOGIN_ALLOWED_ALGORITHMS,
);
function formatHeadlessParsedKeyLabel(
kid: string | undefined,
index: number,
): string {
const trimmedKid = kid?.trim();
if (trimmedKid) {
return trimmedKid;
}
return `key #${index + 1}`;
}
function isTokenEndpointAuthMethod(
value: string,
): value is TokenEndpointAuthMethod {
return (
value === "none" ||
value === "client_secret_basic" ||
value === "private_key_jwt"
);
}
function readMetadataString(
metadata: Record<string, unknown>,
key: string,
): string {
const value = metadata[key];
return typeof value === "string" ? value : "";
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
}
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
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>("private");
const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
// Public Key Registration States
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
useState<TokenEndpointAuthMethod>("client_secret_basic");
const [jwksUri, setJwksUri] = useState("");
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
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);
setInitialStatus(client.status);
const metadata = client.metadata ?? {};
if (typeof metadata.description === "string")
setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
const headlessEnabled = !!metadata.headless_login_enabled;
setHeadlessLoginEnabled(headlessEnabled);
const savedAuthMethod =
client.tokenEndpointAuthMethod ||
(client.type === "pkce" ? "none" : "client_secret_basic");
const headlessAuthMethod = readMetadataString(
metadata,
"headless_token_endpoint_auth_method",
);
const selectedAuthMethod =
headlessEnabled && isTokenEndpointAuthMethod(headlessAuthMethod)
? headlessAuthMethod
: savedAuthMethod;
if (isTokenEndpointAuthMethod(selectedAuthMethod)) {
setTokenEndpointAuthMethod(selectedAuthMethod);
}
const headlessJwksUri = readMetadataString(metadata, "headless_jwks_uri");
if (headlessJwksUri) {
setJwksUri(headlessJwksUri);
} else if (client.jwksUri) {
setJwksUri(client.jwksUri);
} else {
setJwksUri("");
}
// Fallbacks from metadata if top-level fields are empty
if (!client.tokenEndpointAuthMethod && !headlessEnabled) {
const metaAuth = readMetadataString(
metadata,
"token_endpoint_auth_method",
);
if (isTokenEndpointAuthMethod(metaAuth)) {
setTokenEndpointAuthMethod(metaAuth);
}
}
if (!client.jwksUri && !headlessEnabled) {
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
if (metaJwksUri) {
setJwksUri(metaJwksUri);
}
}
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 securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private";
const handleSecurityProfileChange = (profile: SecurityProfile) => {
setClientType(profile);
if (profile === "pkce") {
setTokenEndpointAuthMethod(
headlessLoginEnabled ? "private_key_jwt" : "none",
);
} else {
setTokenEndpointAuthMethod("client_secret_basic");
}
};
const handleHeadlessToggle = (enabled: boolean) => {
setHeadlessLoginEnabled(enabled);
if (clientType === "pkce") {
setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none");
}
};
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 handleStatusChange = (nextStatus: ClientStatus) => {
setStatus(nextStatus);
const statusLabel =
nextStatus === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive");
toast(
t(
"msg.dev.clients.general.status_changed",
"상태가 {{status}}로 변경되었습니다.",
{ status: statusLabel },
),
);
};
const validationErrors: string[] = [];
const trimmedJwksUri = jwksUri.trim();
const currentHeadlessJwksCache = data?.headlessJwksCache;
const parsedKeysForCurrentJwksUri =
headlessLoginEnabled &&
trimmedJwksUri !== "" &&
currentHeadlessJwksCache?.jwksUri === trimmedJwksUri
? (currentHeadlessJwksCache.parsedKeys ?? [])
: [];
const unsupportedParsedAlgorithms = parsedKeysForCurrentJwksUri
.map((key, index) => ({
alg: key.alg?.trim() ?? "",
label: formatHeadlessParsedKeyLabel(key.kid, index),
}))
.filter(
(entry) =>
entry.alg !== "" &&
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(entry.alg),
);
const missingParsedAlgorithms = parsedKeysForCurrentJwksUri
.map((key, index) => ({
alg: key.alg?.trim() ?? "",
label: formatHeadlessParsedKeyLabel(key.kid, index),
}))
.filter((entry) => entry.alg === "");
const unsupportedParsedAlgorithmSummary = unsupportedParsedAlgorithms
.map((entry) => `${entry.label}: ${entry.alg}`)
.join(", ");
const missingParsedAlgorithmSummary = missingParsedAlgorithms
.map((entry) => entry.label)
.join(", ");
const allowedHeadlessAlgorithmsTooltip = t(
"msg.dev.clients.general.public_key.allowed_algorithms_tooltip",
"허용 알고리즘: {{algorithms}}",
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
);
if (headlessLoginEnabled) {
if (!trimmedJwksUri) {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.missing_jwks_uri",
"JWKS URI를 입력해야 합니다.",
),
);
} else if (!isValidUrl(trimmedJwksUri)) {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.invalid_jwks_uri",
"JWKS URI 형식이 올바르지 않습니다.",
),
);
}
if (unsupportedParsedAlgorithms.length > 0) {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
"JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}",
{ details: unsupportedParsedAlgorithmSummary },
),
);
}
if (missingParsedAlgorithms.length > 0) {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.missing_parsed_algorithms",
"JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}",
{ details: missingParsedAlgorithmSummary },
),
);
}
}
const hasValidationErrors = validationErrors.length > 0;
const refreshHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
if (!clientId) throw new Error("Missing client id");
return refreshHeadlessJwksCache(clientId);
},
onSuccess: (result) => {
if (clientId) {
queryClient.setQueryData(["client", clientId], result);
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
}
toast(
t(
"msg.dev.clients.general.public_key.cache_refreshed",
"JWKS 캐시를 새로 고쳤습니다.",
),
);
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message ??
t("msg.common.unknown_error", "unknown error");
toast(
t(
"msg.dev.clients.general.public_key.cache_refresh_failed",
"JWKS 캐시 새로고침에 실패했습니다: {{error}}",
{ error: errorMessage },
),
);
},
});
const revokeHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
if (!clientId) throw new Error("Missing client id");
return revokeHeadlessJwksCache(clientId);
},
onSuccess: () => {
if (clientId) {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
}
toast(
t(
"msg.dev.clients.general.public_key.cache_revoked",
"JWKS 캐시를 삭제했습니다.",
),
);
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message ??
t("msg.common.unknown_error", "unknown error");
toast(
t(
"msg.dev.clients.general.public_key.cache_revoke_failed",
"JWKS 캐시 삭제에 실패했습니다: {{error}}",
{ error: errorMessage },
),
);
},
});
const mutation = useMutation({
mutationFn: async () => {
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
const effectiveTokenEndpointAuthMethod =
clientType === "pkce" && headlessLoginEnabled
? "none"
: tokenEndpointAuthMethod;
const payload: ClientUpsertRequest = {
name,
type: clientType,
scopes: scopeNames,
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
jwksUri:
effectiveTokenEndpointAuthMethod === "private_key_jwt" &&
trimmedJwksUri
? trimmedJwksUri
: undefined,
metadata: {
description,
logo_url: logoUrl,
structured_scopes: scopes,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled,
headless_token_endpoint_auth_method:
clientType === "pkce" && headlessLoginEnabled
? tokenEndpointAuthMethod
: undefined,
headless_jwks_uri:
clientType === "pkce" && headlessLoginEnabled
? trimmedJwksUri
: undefined,
},
};
if (isCreate) {
payload.status = status;
payload.redirectUris = redirectUris
.split(",")
.map((uri) => uri.trim())
.filter(Boolean);
return createClient(payload);
}
const updated = await updateClient(clientId as string, payload);
if (status !== initialStatus) {
await updateClientStatus(clientId as string, status);
}
return updated;
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ["clients"] });
if (status !== initialStatus) {
setInitialStatus(status);
}
if (result?.client?.id) {
navigate(`/clients/${result.client.id}/settings`);
}
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message ??
t("msg.common.unknown_error", "unknown error");
alert(
t(
"msg.dev.clients.general.save_error",
"저장에 실패했습니다: {{error}}",
{
error: errorMessage,
},
),
);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteClient(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["clients"] });
alert(t("msg.dev.clients.deleted", "앱이 삭제되었습니다."));
navigate("/clients");
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message;
alert(
t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", {
error: errorMessage,
}),
);
},
});
const handleDelete = () => {
if (
clientId &&
window.confirm(
t(
"msg.dev.clients.delete_confirm",
"정말로 이 앱을 삭제하시습니까? 이 작업은 되돌릴 수 없습니다.",
),
)
) {
deleteMutation.mutate(clientId);
}
};
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 publicKeyStatusTone = headlessLoginEnabled
? hasValidationErrors
? "border-destructive/40 bg-destructive/5"
: "border-primary/30 bg-primary/5"
: "border-border bg-muted/20";
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">
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{displayName}</span>
{!isCreate && (
<>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.settings", "Settings")}
</span>
</>
)}
</nav>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to={isCreate ? "/clients" : `/clients/${clientId}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<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>
<p className="text-muted-foreground">
{t(
"ui.dev.clients.general.subtitle",
"앱 정보, 권한 스코프, 보안 설정을 관리합니다.",
)}
</p>
</div>
</div>
</div>
{!isCreate && (
<Badge
variant={status === "active" ? "info" : "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", "Federation")}
</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">
<div className="flex items-center justify-between mb-6">
<div>
<CardTitle className="text-xl font-bold mb-2">
{t(
"ui.dev.clients.general.identity.title",
"Application Identity",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.identity.subtitle",
"앱 이름과 설명, 로고를 설정합니다.",
)}
</CardDescription>
</div>
</div>
<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-5">
<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 className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.dev.clients.table.status", "상태")}
</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => handleStatusChange("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => handleStatusChange("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</div>
</div>
</div>
{/* 2. Scopes */}
<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">
{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">
{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 font-bold">
{t("ui.dev.clients.general.scopes.table.delete", "Delete")}
</th>
</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 */}
<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",
securityProfile === "private"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
>
<input
className="sr-only"
type="radio"
name="security-profile"
checked={securityProfile === "private"}
onChange={() => handleSecurityProfileChange("private")}
/>
<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.private",
"Server side App",
)}
</span>
<span className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.security.private_help",
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{securityProfile === "private" ? "✓" : ""}
</span>
</label>
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
securityProfile === "pkce"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
>
<input
className="sr-only"
type="radio"
name="security-profile"
checked={securityProfile === "pkce"}
onChange={() => handleSecurityProfileChange("pkce")}
/>
<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.pkce", "PKCE")}
</span>
<span className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.security.pkce_help",
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{securityProfile === "pkce" ? "✓" : ""}
</span>
{securityProfile === "pkce" && (
<div
className="mt-4 pt-4 border-t border-primary/20 flex items-center justify-between"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="space-y-0.5">
<Label
className="text-xs font-bold cursor-pointer"
htmlFor="headless-login-toggle"
>
{t(
"ui.dev.clients.general.security.headless_login_enable",
"Headless Login (자체 로그인 UI 사용)",
)}
</Label>
<p className="text-[10px] text-muted-foreground">
{t(
"ui.dev.clients.general.security.headless_login_enable_help",
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
)}
</p>
</div>
<Switch
id="headless-login-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
/>
</div>
)}
</label>
</div>
</CardContent>
</Card>
{/* 4. Public Key Registration (Headless Login) */}
{clientType === "pkce" && headlessLoginEnabled && (
<Card className="glass-panel border-primary/20">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
{t(
"ui.dev.clients.general.public_key.title",
"Public Key Registration",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.public_key.subtitle",
"Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.",
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className={cn("rounded-xl border p-4", publicKeyStatusTone)}>
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-bold text-foreground">
{t(
"ui.dev.clients.general.public_key.headless_toggle",
"Headless Login 허용 여부",
)}
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.headless_help",
"애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다.",
)}
</p>
</div>
<Badge
variant="default"
className="bg-primary/20 text-primary border-primary/30"
>
{t("ui.common.enabled", "Enabled")}
</Badge>
</div>
</div>
<div className="space-y-4">
<div className="space-y-3 rounded-xl border border-border bg-muted/5 p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm font-semibold" htmlFor="jwks-uri">
{t(
"ui.dev.clients.general.public_key.jwks_uri",
"JWKS URI",
)}
<span className="text-destructive ml-1">*</span>
</Label>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
title={allowedHeadlessAlgorithmsTooltip}
aria-label={t(
"ui.dev.clients.general.public_key.allowed_algorithms_info",
"Headless Login 허용 알고리즘 정보",
)}
>
<Info className="h-4 w-4" />
</Button>
</div>
<Input
id="jwks-uri"
value={jwksUri}
onChange={(e) => setJwksUri(e.target.value)}
placeholder={t(
"ui.dev.clients.general.public_key.jwks_uri_placeholder",
"https://rp.example.com/.well-known/jwks.json",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.jwks_uri_help",
"RP backend가 제공하는 공개키 endpoint URL을 입력하세요.",
)}
</p>
</div>
</div>
<div className="space-y-3 rounded-xl border border-border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<Label className="text-sm font-bold">
{t(
"ui.dev.clients.general.public_key.cache.title",
"JWKS Cache",
)}
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache_help",
"백엔드가 마지막으로 검증한 공개키 캐시 상태입니다.",
)}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refreshHeadlessJwksCacheMutation.mutate()}
disabled={refreshHeadlessJwksCacheMutation.isPending}
>
{refreshHeadlessJwksCacheMutation.isPending
? t("msg.common.requesting", "요청 중...")
: t("ui.common.refresh", "Refresh")}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => {
if (
!currentHeadlessJwksCache ||
revokeHeadlessJwksCacheMutation.isPending
) {
return;
}
const confirmed = window.confirm(
t(
"msg.dev.clients.general.public_key.cache_revoke_confirm",
"JWKS 캐시를 삭제하면 다음 검증 전에 다시 갱신해야 합니다. 계속할까요?",
),
);
if (confirmed) {
revokeHeadlessJwksCacheMutation.mutate();
}
}}
disabled={
!currentHeadlessJwksCache ||
revokeHeadlessJwksCacheMutation.isPending
}
>
{revokeHeadlessJwksCacheMutation.isPending
? t("msg.common.requesting", "요청 중...")
: t(
"ui.dev.clients.general.public_key.revoke_cache",
"Revoke Cache",
)}
</Button>
</div>
</div>
{currentHeadlessJwksCache ? (
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.status",
"Status",
)}
</p>
<Badge variant="info" className="w-fit capitalize">
{currentHeadlessJwksCache.lastRefreshStatus ||
t("ui.common.unknown", "Unknown")}
</Badge>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.uri",
"JWKS URI",
)}
</p>
<p className="break-all font-mono text-xs">
{currentHeadlessJwksCache.jwksUri}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.cached_at",
"Cached At",
)}
</p>
<p>{formatDateTime(currentHeadlessJwksCache.cachedAt)}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.expires_at",
"Expires At",
)}
</p>
<p>
{formatDateTime(currentHeadlessJwksCache.expiresAt)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.last_checked_at",
"Last Checked",
)}
</p>
<p>
{formatDateTime(currentHeadlessJwksCache.lastCheckedAt)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.last_success",
"Last Successful Verification",
)}
</p>
<p>
{formatDateTime(
currentHeadlessJwksCache.lastSuccessfulVerificationAt,
)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.failures",
"Consecutive Failures",
)}
</p>
<p>{currentHeadlessJwksCache.consecutiveFailures ?? 0}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.kids",
"Cached KIDs",
)}
</p>
<p className="font-mono text-xs">
{currentHeadlessJwksCache.cachedKids?.length
? currentHeadlessJwksCache.cachedKids.join(", ")
: "-"}
</p>
</div>
<div className="space-y-1 md:col-span-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.error",
"Last Error",
)}
</p>
<p className="break-words text-xs text-muted-foreground">
{currentHeadlessJwksCache.lastError || "-"}
</p>
</div>
{(unsupportedParsedAlgorithms.length > 0 ||
missingParsedAlgorithms.length > 0) && (
<div className="space-y-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 md:col-span-2">
<p className="text-sm font-semibold text-destructive">
{unsupportedParsedAlgorithms.length > 0
? t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_title",
"지원하지 않는 알고리즘이 감지되었습니다.",
)
: t(
"msg.dev.clients.general.public_key.cache.missing_algorithms_title",
"알고리즘이 선언되지 않았습니다.",
)}
</p>
<p className="text-xs text-destructive">
{unsupportedParsedAlgorithms.length > 0
? t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_help",
"저장 전 JWKS를 수정해 주세요: {{details}}",
{ details: unsupportedParsedAlgorithmSummary },
)
: t(
"msg.dev.clients.general.public_key.cache.missing_algorithms_help",
"저장 전 JWKS 각 키에 `alg`를 명시해 주세요: {{details}}",
{ details: missingParsedAlgorithmSummary },
)}
</p>
</div>
)}
<div className="space-y-3 md:col-span-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.parsed_keys",
"Parsed Keys",
)}
</p>
<p className="text-[11px] text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache.parsed_keys_help",
"Raw JWKS stays hidden. Only parsed key metadata is shown here.",
)}
</p>
</div>
{currentHeadlessJwksCache.parsedKeys?.length ? (
<div className="space-y-3">
{currentHeadlessJwksCache.parsedKeys.map(
(key, index) => {
const normalizedAlgorithm = key.alg?.trim() ?? "";
const isMissingAlgorithm =
normalizedAlgorithm === "";
const isUnsupportedAlgorithm =
!isMissingAlgorithm &&
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(
normalizedAlgorithm,
);
return (
<div
key={`${key.kid || "key"}-${index}`}
className={cn(
"rounded-xl border bg-muted/30 p-3",
isUnsupportedAlgorithm || isMissingAlgorithm
? "border-destructive/50 bg-destructive/5"
: "border-border",
)}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
KID
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kid || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
KTY
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kty || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
USE
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.use || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
ALG
</p>
<p
className={cn(
"break-all rounded-lg border bg-background px-3 py-2 font-mono text-[11px]",
isUnsupportedAlgorithm ||
isMissingAlgorithm
? "border-destructive/50 text-destructive"
: "border-border",
)}
>
{key.alg ||
t(
"msg.dev.clients.general.public_key.cache.missing_algorithm_badge",
"알고리즘 미선언",
)}
</p>
{isMissingAlgorithm && (
<p className="text-[11px] text-destructive">
{t(
"msg.dev.clients.general.public_key.cache.missing_algorithm_reason",
"이 키는 `alg`가 비어 있어서 저장할 수 없습니다.",
)}
</p>
)}
{isUnsupportedAlgorithm && (
<p className="text-[11px] text-destructive">
{t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason",
"이 알고리즘은 Headless Login에서 지원되지 않습니다.",
)}
</p>
)}
</div>
</div>
<div className="mt-3 space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.parsed_key_n",
"N",
)}
</p>
<p className="min-h-16 break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px] leading-5">
{key.n || "-"}
</p>
</div>
</div>
);
},
)}
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache.parsed_keys_empty",
"No parsed JWKS keys are available yet.",
)}
</div>
)}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache_empty",
"아직 캐시된 JWKS가 없습니다. Refresh를 눌러 백엔드 캐시 상태를 조회하세요.",
)}
</div>
)}
</div>
</div>
{hasValidationErrors && (
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-4 animate-in fade-in">
<p className="text-sm font-semibold text-destructive flex items-center gap-2">
<span></span>
{t(
"ui.dev.clients.general.public_key.validation_title",
"저장 전 확인 필요",
)}
</p>
<ul className="mt-2 list-disc space-y-1 pl-6 text-sm text-destructive">
{validationErrors.map((errorMessage) => (
<li key={errorMessage}>{errorMessage}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)}
<div className="flex items-center justify-between border-t border-border pt-4">
<div>
{!isCreate && (
<Button
variant="destructive"
className="gap-2"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
{deleteMutation.isPending
? t("msg.common.requesting", "요청 중...")
: t("ui.common.delete", "삭제")}
</Button>
)}
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button variant="outline" onClick={() => navigate("/clients")}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={
mutation.isPending ||
isLoading ||
name.trim() === "" ||
(isCreate && redirectUris.trim() === "") ||
hasValidationErrors
}
className="shadow-lg shadow-primary/20"
>
{mutation.isPending ? (
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
) : (
<Save size={16} className="mr-2" />
)}
{mutation.isPending
? t("msg.common.saving", "저장 중...")
: isCreate
? t("ui.dev.clients.general.create", "클라이언트 생성")
: t("ui.common.save", "저장")}
</Button>
</div>
</div>
</div>
);
}
export default ClientGeneralPage;