import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowLeft, 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, updateClient, updateClientStatus, } from "../../lib/devApi"; import type { ClientStatus, ClientType, ClientUpsertRequest, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { tryConvertToJwks } from "../../lib/keyUtils"; 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"; function isTokenEndpointAuthMethod( value: string, ): value is TokenEndpointAuthMethod { return ( value === "none" || value === "client_secret_basic" || value === "private_key_jwt" ); } function readMetadataString( metadata: Record, key: string, ): string { const value = metadata[key]; return typeof value === "string" ? value : ""; } function readMetadataObject( metadata: Record, key: string, ): Record | undefined { const value = metadata[key]; if (typeof value !== "object" || value === null || Array.isArray(value)) { return undefined; } return value as Record; } function isValidUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === "https:" || url.protocol === "http:"; } catch { return false; } } function isValidJson(value: string): boolean { if (!value.trim()) return false; try { JSON.parse(value); return true; } catch { return false; } } 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("private"); const [status, setStatus] = useState("active"); const [initialStatus, setInitialStatus] = useState("active"); const [redirectUris, setRedirectUris] = useState(""); // Public Key Registration States const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] = useState("client_secret_basic"); const [jwksSource, setJwksSource] = useState<"uri" | "inline">("inline"); const [jwksUri, setJwksUri] = useState(""); const [jwksText, setJwksText] = useState(""); const [requestObjectSigningAlg, setRequestObjectSigningAlg] = useState("RS256"); const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false); const [scopes, setScopes] = useState(() => [ { 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"); const headlessJwks = readMetadataObject(metadata, "headless_jwks"); if (headlessJwksUri) { setJwksUri(headlessJwksUri); setJwksText(""); setJwksSource("uri"); } else if (headlessJwks) { setJwksText(JSON.stringify(headlessJwks, null, 2)); setJwksUri(""); setJwksSource("inline"); } else if (client.jwksUri) { setJwksUri(client.jwksUri); setJwksText(""); setJwksSource("uri"); } else if (client.jwks) { setJwksText( typeof client.jwks === "string" ? client.jwks : JSON.stringify(client.jwks, null, 2), ); setJwksUri(""); setJwksSource("inline"); } else { setJwksUri(""); setJwksText(""); setJwksSource("inline"); } // 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 && !client.jwks && !headlessEnabled) { const metaJwksUri = readMetadataString(metadata, "jwks_uri"); if (metaJwksUri) { setJwksUri(metaJwksUri); setJwksSource("uri"); } } const savedRequestObjectSigningAlg = readMetadataString( metadata, "request_object_signing_alg", ); if (savedRequestObjectSigningAlg) { setRequestObjectSigningAlg(savedRequestObjectSigningAlg); } else if (savedAuthMethod === "private_key_jwt") { setRequestObjectSigningAlg("RS256"); } 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"); if (enabled && requestObjectSigningAlg.trim() === "") { setRequestObjectSigningAlg("RS256"); } } }; const addScope = () => { const newId = String(Date.now()); setScopes([ ...scopes, { id: newId, name: "", description: "", mandatory: false }, ]); }; const updateScope = ( 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 }, ), ); }; // Convert on blur or change if desired, here we try to convert before validation const finalJwksText = tryConvertToJwks(jwksText); const validationErrors: string[] = []; const trimmedJwksUri = jwksUri.trim(); const trimmedJwksText = finalJwksText.trim(); const trimmedRequestObjectSigningAlg = requestObjectSigningAlg.trim(); if (headlessLoginEnabled) { if (jwksSource === "uri") { 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 형식이 올바르지 않습니다.", ), ); } } else if (jwksSource === "inline") { if (!trimmedJwksText) { validationErrors.push( t( "msg.dev.clients.general.public_key.validation.missing_jwks_inline", "공개키(JWKS 또는 SSH-RSA)를 입력해야 합니다.", ), ); } else if (!isValidJson(trimmedJwksText)) { validationErrors.push( t( "msg.dev.clients.general.public_key.validation.invalid_jwks_inline", "입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다.", ), ); } } if (trimmedRequestObjectSigningAlg === "") { validationErrors.push( t( "msg.dev.clients.general.public_key.validation.headless_requires_alg", "Request Object Signing Algorithm (예: RS256)을 입력해야 합니다.", ), ); } } const hasValidationErrors = validationErrors.length > 0; const mutation = useMutation({ mutationFn: async () => { const scopeNames = scopes.map((scope) => scope.name).filter(Boolean); let finalJwks: ClientUpsertRequest["jwks"]; if (jwksSource === "inline" && trimmedJwksText) { try { finalJwks = JSON.parse(trimmedJwksText); } catch (e) { throw new Error("Invalid Public Key Format"); } } const effectiveTokenEndpointAuthMethod = clientType === "pkce" && headlessLoginEnabled ? "none" : tokenEndpointAuthMethod; const payload: ClientUpsertRequest = { name, type: clientType, scopes: scopeNames, tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod, jwksUri: effectiveTokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "uri" ? trimmedJwksUri : undefined, jwks: effectiveTokenEndpointAuthMethod === "private_key_jwt" ? finalJwks : undefined, metadata: { description, logo_url: logoUrl, structured_scopes: scopes, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, request_object_signing_alg: trimmedRequestObjectSigningAlg, headless_login_enabled: headlessLoginEnabled, headless_token_endpoint_auth_method: clientType === "pkce" && headlessLoginEnabled ? tokenEndpointAuthMethod : undefined, headless_jwks_uri: clientType === "pkce" && headlessLoginEnabled && jwksSource === "uri" ? trimmedJwksUri : undefined, headless_jwks: clientType === "pkce" && headlessLoginEnabled && jwksSource === "inline" ? finalJwks : 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 (
{t("msg.dev.clients.general.loading", "Loading client...")}
); } if (!isCreate && (error || !data)) { const errMsg = (error as AxiosError<{ error?: string }>).response?.data?.error ?? (error as Error)?.message; return (
{t( "msg.dev.clients.general.load_error", "Error loading client: {{error}}", { error: errMsg || t("msg.common.unknown_error", "unknown error"), }, )}
); } 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 (

{isCreate ? t("ui.dev.clients.general.title_create", "Create Client") : t("ui.dev.clients.general.title_edit", "Client Settings")}

{t( "ui.dev.clients.general.subtitle", "앱 정보, 권한 스코프, 보안 설정을 관리합니다.", )}

{!isCreate && ( {status === "active" ? t("ui.common.status.active", "Active") : t("ui.common.status.inactive", "Inactive")} )}
{!isCreate && ( <> {t("ui.dev.clients.details.tab.connection", "Federation")} {t("ui.dev.clients.details.tab.consents", "Consent & Users")} {t("ui.dev.clients.details.tab.settings", "Settings")} )}
{/* 1. Application Identity */}
{t( "ui.dev.clients.general.identity.title", "Application Identity", )} {t( "msg.dev.clients.general.identity.subtitle", "앱 이름과 설명, 로고를 설정합니다.", )}
setName(e.target.value)} placeholder={t( "ui.dev.clients.general.identity.name_placeholder", "My Awesome Application", )} />