import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ExternalLink, Info, Plus, Save, Shield, ShieldHalf, Sparkles, Trash2, Upload, X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate, useParams } from "react-router-dom"; import { PageHeader } from "../../../../common/core/components/page"; import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../../components/ui/card"; import { CopyButton } from "../../components/ui/copy-button"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { Switch } from "../../components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../components/ui/table"; import { Textarea } from "../../components/ui/textarea"; import { toast } from "../../components/ui/use-toast"; import type { ClientStatus, ClientType, ClientUpsertRequest, TenantSummary, } from "../../lib/devApi"; import { type ClientRelation, createClient, deleteClient, fetchClient, fetchClientRelations, fetchTenants, refreshHeadlessJwksCache, revokeHeadlessJwksCache, updateClient, updateClientStatus, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; import { fetchMe, type UserProfile } from "../auth/authApi"; import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { ClientDetailTabs } from "./ClientDetailTabs"; import { TenantAccessPicker } from "./components/TenantAccessPicker"; import { claimDateTimeValueToInputString, dateTimeInputToUnixSeconds, getBrowserTimeZone, getSupportedTimeZones, } from "./rpClaimDateTime"; interface ScopeItem { id: string; name: string; description: string; mandatory: boolean; locked?: boolean; } interface ScopeCandidate { id: string; name: string; description: string; source: "standard" | "custom_claim" | "manual"; } type ClaimNamespace = "rp_claims"; type ClaimValueType = | "text" | "number" | "float" | "boolean" | "array" | "object" | "date" | "datetime"; type CustomClaimPermission = "admin_only" | "user_and_admin"; interface IdTokenClaimItem { id: string; namespace: ClaimNamespace; key: string; value: string; timeZone: string; valueType: ClaimValueType; nullable: boolean; readPermission: CustomClaimPermission; writePermission: CustomClaimPermission; } 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( 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, key: string, ): string { const value = metadata[key]; return typeof value === "string" ? value : ""; } function isClaimNamespace(value: string): value is ClaimNamespace { return value === "rp_claims"; } function isClaimValueType(value: string): value is ClaimValueType { return ( value === "text" || value === "number" || value === "float" || value === "boolean" || value === "array" || value === "object" || value === "date" || value === "datetime" ); } function isCustomClaimPermission( value: unknown, ): value is CustomClaimPermission { return value === "admin_only" || value === "user_and_admin"; } function createIdTokenClaimItem(id: string): IdTokenClaimItem { return { id, namespace: "rp_claims", key: "", value: "", timeZone: getBrowserTimeZone(), valueType: "text", nullable: false, readPermission: "admin_only", writePermission: "admin_only", }; } function normalizeIdTokenClaimPermissions( claim: IdTokenClaimItem, ): IdTokenClaimItem { if (claim.writePermission !== "user_and_admin") { return claim; } return { ...claim, readPermission: "user_and_admin", }; } function readIdTokenClaimsMetadata( metadata: Record, ): IdTokenClaimItem[] { const rawClaims = metadata.id_token_claims; if (!Array.isArray(rawClaims)) { return []; } return rawClaims .map((item, index) => { if (!item || typeof item !== "object") { return null; } const record = item as Record; const namespaceValue = typeof record.namespace === "string" && isClaimNamespace(record.namespace) ? record.namespace : null; if (namespaceValue === null) { return null; } const keyValue = typeof record.key === "string" ? record.key : ""; const rawValue = record.value; const valueTypeValue = typeof record.valueType === "string" && isClaimValueType(record.valueType) ? record.valueType : "text"; const timeZoneValue = getBrowserTimeZone(); const valueValue = valueTypeValue === "date" || valueTypeValue === "datetime" ? claimDateTimeValueToInputString( rawValue, "", valueTypeValue, timeZoneValue, ) : typeof rawValue === "string" ? rawValue : rawValue == null ? "" : JSON.stringify(rawValue); return normalizeIdTokenClaimPermissions({ id: `claim-${index + 1}`, namespace: namespaceValue, key: keyValue, value: valueValue, timeZone: timeZoneValue, valueType: valueTypeValue, nullable: record.nullable === true, readPermission: isCustomClaimPermission(record.readPermission) ? record.readPermission : "admin_only", writePermission: isCustomClaimPermission(record.writePermission) ? record.writePermission : "admin_only", }); }) .filter((item): item is IdTokenClaimItem => item !== null); } function normalizeClaimPreviewValue( value: string, valueType: ClaimValueType, nullable: boolean, timeZone: string, ): unknown { const trimmed = value.trim(); if (nullable && trimmed === "") { return null; } if (valueType === "date" || valueType === "datetime") { if (trimmed === "") return ""; const unixSeconds = dateTimeInputToUnixSeconds( trimmed, valueType, timeZone, ); return unixSeconds ?? trimmed; } if (valueType === "number" || valueType === "float") { if (trimmed === "") return ""; const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : trimmed; } if (valueType === "boolean") { return ["true", "1", "yes", "on"].includes(trimmed.toLowerCase()); } if (valueType === "array") { if (trimmed === "") return []; try { if (trimmed.startsWith("[")) { const parsed = JSON.parse(trimmed); return Array.isArray(parsed) ? parsed : [parsed]; } } catch { // Fall through to comma-separated parsing. } return trimmed .split(",") .map((part) => part.trim()) .filter(Boolean); } if (valueType === "object") { if (trimmed === "") return {}; try { const parsed = JSON.parse(trimmed); return parsed; } catch { return trimmed; } } return trimmed; } function isJsonObjectValue(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function isIntegerClaimDefaultValue(value: string) { return /^-?\d+$/.test(value); } function isFloatClaimDefaultValue(value: string) { return /^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value); } function isValidDateInputValue(value: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const date = new Date(`${value}T00:00:00Z`); if (Number.isNaN(date.getTime())) return false; return date.toISOString().slice(0, 10) === value; } function isValidDateTimeInputValue(value: string) { if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(value)) { return false; } const date = new Date(value); return !Number.isNaN(date.getTime()); } function normalizedClaimValue(claim: IdTokenClaimItem): string | number { const value = claim.value.trim(); if (claim.valueType !== "date" && claim.valueType !== "datetime") { return value; } if (value === "") { return value; } return ( dateTimeInputToUnixSeconds(value, claim.valueType, claim.timeZone) ?? value ); } function claimDefaultValueValidationError(claim: IdTokenClaimItem) { const value = claim.value.trim(); if (value === "") { return null; } switch (claim.valueType) { case "number": return isIntegerClaimDefaultValue(value) ? null : t( "msg.dev.clients.general.id_token_claims.invalid_default_value", "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})", { key: claim.key || "-", valueType: claim.valueType }, ); case "float": return isFloatClaimDefaultValue(value) ? null : t( "msg.dev.clients.general.id_token_claims.invalid_default_value", "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})", { key: claim.key || "-", valueType: claim.valueType }, ); case "boolean": return value === "true" || value === "false" ? null : t( "msg.dev.clients.general.id_token_claims.invalid_default_value", "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})", { key: claim.key || "-", valueType: claim.valueType }, ); case "array": { try { return Array.isArray(JSON.parse(value)) ? null : t( "msg.dev.clients.general.id_token_claims.invalid_default_value", "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})", { key: claim.key || "-", valueType: claim.valueType }, ); } catch { return t( "msg.dev.clients.general.id_token_claims.invalid_default_value", "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})", { key: claim.key || "-", valueType: claim.valueType }, ); } } case "object": { try { return isJsonObjectValue(JSON.parse(value)) ? null : t( "msg.dev.clients.general.id_token_claims.invalid_default_value", "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})", { key: claim.key || "-", valueType: claim.valueType }, ); } catch { return t( "msg.dev.clients.general.id_token_claims.invalid_default_value", "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})", { key: claim.key || "-", valueType: claim.valueType }, ); } } case "date": return isValidDateInputValue(value) ? null : t( "msg.dev.clients.general.id_token_claims.invalid_default_value", "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})", { key: claim.key || "-", valueType: claim.valueType }, ); case "datetime": return isValidDateTimeInputValue(value) ? null : t( "msg.dev.clients.general.id_token_claims.invalid_default_value", "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})", { key: claim.key || "-", valueType: claim.valueType }, ); default: return null; } } function claimDefaultInputType(valueType: ClaimValueType) { if (valueType === "date") return "date"; if (valueType === "datetime") return "datetime-local"; return "text"; } function claimDefaultInputMode(valueType: ClaimValueType) { if (valueType === "number") return "numeric"; if (valueType === "float") return "decimal"; return undefined; } function claimDefaultInputPattern(valueType: ClaimValueType) { if (valueType === "number") return "-?[0-9]*"; if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)"; return undefined; } function buildIdTokenClaimsPreview( items: IdTokenClaimItem[], ): Record { const preview: Record = {}; const rpClaims: Record = {}; for (const item of items) { const key = item.key.trim(); if (!key) { continue; } rpClaims[key] = normalizeClaimPreviewValue( item.value, item.valueType, item.nullable, item.timeZone, ); } if (Object.keys(rpClaims).length > 0) { preview.rp_claims = rpClaims; } return preview; } function isValidUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === "https:" || url.protocol === "http:"; } catch { return false; } } function isValidBackchannelLogoutUrl(value: string): boolean { const trimmed = value.trim(); if (!trimmed) { return true; } try { const url = new URL(trimmed); if (url.hash) { return false; } if (url.protocol === "https:") { return true; } if (url.protocol !== "http:") { return false; } const host = url.hostname.toLowerCase(); if ( host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "host.docker.internal" ) { return true; } if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) { return ( host.startsWith("10.") || host.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[0-1])\./.test(host) || host.startsWith("169.254.") ); } // Docker service names and other single-label local hosts are allowed // only for HTTP local development use. return !host.includes("."); } 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 auth = useAuth(); const params = useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const hasAccessToken = Boolean(auth.user?.access_token); const clientId = params.id; const isCreate = !clientId; const userProfile = auth.user?.profile as Record | undefined; const systemRole = resolveProfileRole(userProfile); const { data: me, isLoading: isLoadingMe } = useQuery({ queryKey: ["userMe"], queryFn: fetchMe, enabled: hasAccessToken, }); const currentUserId = me?.id ?? auth.user?.profile.sub; const effectiveSystemRole = me?.role?.trim() || systemRole; const { hasDeveloperAccess: hasClientCreateAccess, isDeveloperRequestPending, canRequestDeveloperAccess, isLoadingDeveloperAccessGate, } = useDeveloperAccessGate({ hasAccessToken, profileRole: effectiveSystemRole, tenantId: userProfile?.tenant_id as string | undefined, requiredPages: ["client_create"], isLoadingIdentity: isLoadingMe, }); const { data, isLoading, error } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId as string), enabled: !isCreate, }); const { data: relationData } = useQuery({ queryKey: ["client-relations", clientId], queryFn: () => fetchClientRelations(clientId as string), enabled: !isCreate, retry: false, }); const { data: tenantData } = useQuery({ queryKey: ["tenants", "all"], queryFn: () => fetchTenants(), }); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [logoUrl, setLogoUrl] = useState(""); const [logoPreviewStatus, setLogoPreviewStatus] = useState< "idle" | "loading" | "loaded" | "error" >("idle"); const [clientType, setClientType] = useState("private"); const [status, setStatus] = useState("active"); const [initialStatus, setInitialStatus] = useState("active"); const [redirectUris, setRedirectUris] = useState(""); const [backchannelLogoutUri, setBackchannelLogoutUri] = useState(""); const [ backchannelLogoutSessionRequired, setBackchannelLogoutSessionRequired, ] = useState(false); const [ isBackchannelSessionRequiredInfoOpen, setIsBackchannelSessionRequiredInfoOpen, ] = useState(false); const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false); const [allowedTenantIds, setAllowedTenantIds] = useState([]); const [autoLoginSupported, setAutoLoginSupported] = useState(false); const [autoLoginUrl, setAutoLoginUrl] = useState(""); // Public Key Registration States const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] = useState("client_secret_basic"); const [jwksUri, setJwksUri] = useState(""); const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false); const [isScopePickerOpen, setIsScopePickerOpen] = useState(false); const [scopes, setScopes] = useState(() => [ { id: "1", name: "openid", description: t("msg.dev.clients.scopes.openid", "OIDC 인증 필수 스코프"), mandatory: true, }, { id: "2", name: "tenant", description: t("msg.dev.clients.scopes.tenant", "소속 테넌트 정보 접근"), mandatory: false, }, { id: "3", name: "profile", description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"), mandatory: false, }, { id: "4", name: "email", description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"), mandatory: false, }, ]); const [idTokenClaims, setIdTokenClaims] = useState([]); const browserTimeZone = useMemo(() => getBrowserTimeZone(), []); const timeZoneOptions = useMemo( () => getSupportedTimeZones(browserTimeZone), [browserTimeZone], ); const tenantScopeDescription = t( "msg.dev.clients.scopes.tenant", "소속 테넌트 정보 접근", ); const buildTenantScope = useCallback( (id: string): ScopeItem => ({ id, name: "tenant", description: tenantScopeDescription, mandatory: true, locked: true, }), [tenantScopeDescription], ); const normalizeScopesForTenantAccess = useCallback( (nextScopes: ScopeItem[], restricted: boolean): ScopeItem[] => { const normalized = nextScopes.map((scope) => { if (scope.name.trim() !== "tenant") { return scope; } if (restricted) { return { ...scope, description: scope.description || tenantScopeDescription, mandatory: true, locked: true, }; } return { ...scope, description: scope.description || tenantScopeDescription, locked: false, }; }); if ( restricted && !normalized.some((scope) => scope.name.trim() === "tenant") ) { normalized.push(buildTenantScope(`tenant-${Date.now()}`)); } const openidScopes = normalized.filter( (scope) => scope.name.trim() === "openid", ); const tenantScopes = normalized.filter( (scope) => scope.name.trim() === "tenant", ); const remainingScopes = normalized.filter((scope) => { const name = scope.name.trim(); return name !== "openid" && name !== "tenant"; }); return [...openidScopes, ...tenantScopes, ...remainingScopes]; }, [buildTenantScope, tenantScopeDescription], ); const supportedScopeCandidates = useMemo( () => [ { id: "standard-openid", name: "openid", description: t( "msg.dev.clients.scopes.openid", "OIDC 인증 필수 스코프", ), source: "standard", }, { id: "standard-profile", name: "profile", description: t( "msg.dev.clients.scopes.profile", "기본 프로필 정보 접근", ), source: "standard", }, { id: "standard-email", name: "email", description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"), source: "standard", }, { id: "standard-tenant", name: "tenant", description: tenantScopeDescription, source: "standard", }, { id: "standard-offline-access", name: "offline_access", description: t( "msg.dev.clients.scopes.offline_access", "refresh token 발급 요청", ), source: "standard", }, ], [tenantScopeDescription], ); const customClaimScopeCandidates = useMemo(() => { const seen = new Set(); const candidates: ScopeCandidate[] = []; for (const claim of idTokenClaims) { const name = claim.key.trim(); if (!name || seen.has(name)) { continue; } seen.add(name); candidates.push({ id: `custom-claim-${name}`, name, description: t( "msg.dev.clients.scopes.custom_claim", "Custom Claim 요청 scope", ), source: "custom_claim", }); } return candidates; }, [idTokenClaims]); const scopeCandidates = useMemo( () => [ ...supportedScopeCandidates, ...customClaimScopeCandidates, { id: "manual-scope", name: "", description: t( "msg.dev.clients.scopes.manual", "목록에 없는 scope를 직접 입력합니다.", ), source: "manual", }, ], [customClaimScopeCandidates, supportedScopeCandidates], ); const existingScopeNames = useMemo(() => { const names = new Set(); for (const scope of scopes) { const name = scope.name.trim(); if (name) { names.add(name); } } return names; }, [scopes]); useEffect(() => { if (!data) return; const { client } = data; const metadata = client.metadata ?? {}; const headlessEnabled = !!metadata.headless_login_enabled; setName(client.name || client.id); setClientType(headlessEnabled ? "private" : client.type); setStatus(client.status); setInitialStatus(client.status); if (typeof metadata.description === "string") setDescription(metadata.description); if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url); setBackchannelLogoutUri( client.backchannelLogoutUri || readMetadataString(metadata, "backchannel_logout_uri"), ); setBackchannelLogoutSessionRequired( client.backchannelLogoutSessionRequired === true || metadata.backchannel_logout_session_required === true, ); setAutoLoginSupported(metadata.auto_login_supported === true); if (typeof metadata.auto_login_url === "string") setAutoLoginUrl(metadata.auto_login_url); setHeadlessLoginEnabled(headlessEnabled); const restrictedTenants = Array.isArray(metadata.allowed_tenants) ? metadata.allowed_tenants .map((value) => (typeof value === "string" ? value.trim() : "")) .filter(Boolean) : []; setTenantAccessRestricted( restrictedTenants.length > 0 || metadata.tenant_access_restricted === true, ); setAllowedTenantIds(restrictedTenants); 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( normalizeScopesForTenantAccess( savedScopes, restrictedTenants.length > 0 || metadata.tenant_access_restricted === true, ), ); } else { setScopes( normalizeScopesForTenantAccess( client.scopes.map((s, idx) => ({ id: String(idx + 1), name: s, description: "", mandatory: s === "openid", })), restrictedTenants.length > 0 || metadata.tenant_access_restricted === true, ), ); } setIdTokenClaims(readIdTokenClaimsMetadata(metadata)); }, [data, normalizeScopesForTenantAccess]); const securityProfile: SecurityProfile = clientType === "pkce" ? "pkce" : "private"; const canEditExistingClientGeneralSettings = effectiveSystemRole === "super_admin" || relationData?.items?.some( (item: ClientRelation) => item.subject === `User:${currentUserId}` && (item.relation === "admins" || item.relation === "config_editor"), ) === true; const isGeneralSettingsReadOnly = !isCreate && relationData != null && !canEditExistingClientGeneralSettings; const trimmedLogoUrl = logoUrl.trim(); const trimmedAutoLoginUrl = autoLoginUrl.trim(); const hasLogoUrl = trimmedLogoUrl.length > 0; const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl); const trimmedBackchannelLogoutUri = backchannelLogoutUri.trim(); const hasBackchannelLogoutUri = trimmedBackchannelLogoutUri.length > 0; const hasValidBackchannelLogoutUri = !hasBackchannelLogoutUri || isValidBackchannelLogoutUrl(trimmedBackchannelLogoutUri); const hasValidAutoLoginUrl = !autoLoginSupported || (trimmedAutoLoginUrl.length > 0 && isValidUrl(trimmedAutoLoginUrl)); useEffect(() => { if (!hasLogoUrl) { setLogoPreviewStatus("idle"); return; } if (!hasValidLogoUrl) { setLogoPreviewStatus("error"); return; } setLogoPreviewStatus("loading"); }, [hasLogoUrl, hasValidLogoUrl]); const handleSecurityProfileChange = (profile: SecurityProfile) => { setClientType(profile); if (profile === "pkce") { setHeadlessLoginEnabled(false); setTokenEndpointAuthMethod("none"); } else { setTokenEndpointAuthMethod( headlessLoginEnabled ? "private_key_jwt" : "client_secret_basic", ); } }; const handleHeadlessToggle = (enabled: boolean) => { setHeadlessLoginEnabled(enabled); if (enabled) { setClientType("private"); setTokenEndpointAuthMethod("private_key_jwt"); return; } if (clientType === "private") { setTokenEndpointAuthMethod("client_secret_basic"); } }; const handleTenantAccessToggle = (enabled: boolean) => { setTenantAccessRestricted(enabled); setScopes((current) => normalizeScopesForTenantAccess(current, enabled)); }; const toggleAllowedTenant = (tenantId: string) => { setAllowedTenantIds((current) => current.includes(tenantId) ? current.filter((id) => id !== tenantId) : [...current, tenantId], ); }; const handleSelectAllowedTenant = (tenantId: string) => { setAllowedTenantIds((current) => current.includes(tenantId) ? current : [...current, tenantId], ); }; const addScope = () => { setIsScopePickerOpen((current) => !current); }; const selectScopeCandidate = (candidate: ScopeCandidate) => { const name = candidate.name.trim(); if (name && existingScopeNames.has(name)) { setIsScopePickerOpen(false); return; } const newScope: ScopeItem = { id: `scope-${candidate.source}-${name || "manual"}-${Date.now()}`, name, description: candidate.source === "manual" ? "" : candidate.description, mandatory: false, }; setScopes((current) => normalizeScopesForTenantAccess( [...current, newScope], tenantAccessRestricted, ), ); setIsScopePickerOpen(false); }; const updateScope = ( id: string, field: K, value: ScopeItem[K], ) => { setScopes((current) => current.map((scope) => { if (scope.id !== id) { return scope; } if (scope.locked) { return scope; } return { ...scope, [field]: value }; }), ); }; const removeScope = (id: string) => { setScopes((current) => current.filter((scope) => scope.id !== id || scope.locked === true), ); }; const addIdTokenClaim = () => { setIdTokenClaims((current) => [ ...current, createIdTokenClaimItem(`claim-${Date.now()}`), ]); }; const updateIdTokenClaim = ( id: string, field: K, value: IdTokenClaimItem[K], ) => { setIdTokenClaims((current) => current.map((claim) => { if (claim.id !== id) { return claim; } return { ...claim, [field]: value }; }), ); }; const setIdTokenClaimPermissionAllowed = ( id: string, field: "readPermission" | "writePermission", allowed: boolean, ) => { const permission = allowed ? "user_and_admin" : "admin_only"; setIdTokenClaims((current) => current.map((claim) => { if (claim.id !== id) { return claim; } return normalizeIdTokenClaimPermissions({ ...claim, [field]: permission, }); }), ); }; const removeIdTokenClaim = (id: string) => { setIdTokenClaims((current) => current.filter((claim) => claim.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(", ") }, ); const normalizedIdTokenClaimItems = idTokenClaims.map((claim) => normalizeIdTokenClaimPermissions({ ...claim, key: claim.key.trim(), value: claim.value.trim(), }), ); const normalizedIdTokenClaims = normalizedIdTokenClaimItems.map((claim) => { const { timeZone: _timeZone, value: _value, ...persisted } = claim; return { ...persisted, value: normalizedClaimValue(claim), }; }); 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 }, ), ); } } if (tenantAccessRestricted && allowedTenantIds.length === 0) { validationErrors.push( t( "ui.dev.clients.general.tenant_access.validation_required", "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다.", ), ); } if (autoLoginSupported && !hasValidAutoLoginUrl) { validationErrors.push( t( "msg.dev.clients.general.auto_login.invalid_url", "자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.", ), ); } const claimValidationErrors: string[] = []; const seenClaimKeys = new Set(); for (const claim of normalizedIdTokenClaimItems) { if (!claim.key) { claimValidationErrors.push( t( "msg.dev.clients.general.id_token_claims.key_required", "Claim key를 입력해야 합니다.", ), ); continue; } const keySignature = `${claim.namespace}:${claim.key}`; if (seenClaimKeys.has(keySignature)) { claimValidationErrors.push( t( "msg.dev.clients.general.id_token_claims.duplicate_key", "중복된 claim key가 있습니다: {{namespace}}.{{key}}", { namespace: t( "ui.dev.clients.general.id_token_claims.namespace_rp_claims", "rp_claims", ), key: claim.key, }, ), ); continue; } seenClaimKeys.add(keySignature); const defaultValueError = claimDefaultValueValidationError(claim); if (defaultValueError) { claimValidationErrors.push(defaultValueError); } } validationErrors.push(...claimValidationErrors); const hasValidationErrors = validationErrors.length > 0; const idTokenClaimPreview = buildIdTokenClaimsPreview( normalizedIdTokenClaimItems, ); const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2); const tenantOptions: TenantSummary[] = tenantData?.items ?? []; const selectedAllowedTenants = allowedTenantIds .map((tenantId) => tenantOptions.find((item) => item.id === tenantId)) .filter((tenant): tenant is TenantSummary => tenant != null); 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 () => { if (hasLogoUrl && !hasValidLogoUrl) { throw new Error( t( "msg.dev.clients.general.identity.logo_invalid", "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.", ), ); } if (hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri) { throw new Error( t( "msg.dev.clients.general.backchannel_logout.invalid", "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.", ), ); } if (isGeneralSettingsReadOnly) { throw new Error( t( "msg.dev.clients.general.read_only_forbidden", "이 RP의 일반 설정을 수정할 권한이 없습니다.", ), ); } if (autoLoginSupported && !hasValidAutoLoginUrl) { throw new Error( t( "msg.dev.clients.general.auto_login.invalid_url", "자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.", ), ); } const normalizedScopes = normalizeScopesForTenantAccess( scopes, tenantAccessRestricted, ); const normalizedAllowedTenantIds = Array.from( new Set(allowedTenantIds.map((id) => id.trim()).filter(Boolean)), ); const scopeNames = normalizedScopes .map((scope) => scope.name.trim()) .filter(Boolean); const persistedClientType = headlessLoginEnabled ? "private" : clientType; const effectiveTokenEndpointAuthMethod = tokenEndpointAuthMethod; const payload: ClientUpsertRequest = { name, type: persistedClientType, scopes: scopeNames, tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod, jwksUri: (headlessLoginEnabled || effectiveTokenEndpointAuthMethod === "private_key_jwt") && trimmedJwksUri ? trimmedJwksUri : undefined, backchannelLogoutUri: trimmedBackchannelLogoutUri || undefined, backchannelLogoutSessionRequired: trimmedBackchannelLogoutUri !== "" ? backchannelLogoutSessionRequired : false, metadata: { description, logo_url: trimmedLogoUrl, auto_login_supported: autoLoginSupported, auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined, structured_scopes: normalizedScopes, id_token_claims: normalizedIdTokenClaims, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, headless_login_enabled: headlessLoginEnabled, headless_token_endpoint_auth_method: headlessLoginEnabled ? tokenEndpointAuthMethod : undefined, headless_jwks_uri: headlessLoginEnabled ? trimmedJwksUri : undefined, tenant_access_restricted: tenantAccessRestricted, allowed_tenants: tenantAccessRestricted ? normalizedAllowedTenantIds : [], backchannel_logout_uri: trimmedBackchannelLogoutUri || undefined, backchannel_logout_session_required: trimmedBackchannelLogoutUri !== "" ? backchannelLogoutSessionRequired : undefined, }, }; if (isCreate) { payload.status = status; payload.redirectUris = redirectUris .split(",") .map((uri) => uri.trim()) .filter(Boolean); return createClient(payload); } await queryClient.cancelQueries({ queryKey: ["client", clientId] }); const updated = await updateClient(clientId as string, payload); if (status !== initialStatus) { await updateClientStatus(clientId as string, status); } return updated; }, onSuccess: (result) => { const resultClientId = result?.client?.id ?? clientId; if (resultClientId) { queryClient.setQueryData(["client", resultClientId], result); } queryClient.invalidateQueries({ queryKey: ["clients"] }); if (status !== initialStatus) { setInitialStatus(status); } if (result?.client?.id) { navigate(`/clients/${result.client.id}/settings`); } toast(t("msg.dev.clients.general.saved", "설정이 저장되었습니다.")); }, onError: (err) => { const axiosError = err as AxiosError<{ error?: string }>; if (axiosError.response?.status === 403) { alert( isCreate ? t( "msg.dev.clients.general.create_forbidden", "이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요.", ) : t( "msg.dev.clients.general.save_forbidden", "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.", ), ); return; } const errorMessage = axiosError.response?.data?.error ?? (err as Error)?.message ?? t("msg.common.unknown_error", "unknown error"); toast( t( "msg.dev.clients.general.save_error", "저장에 실패했습니다: {{error}}", { error: errorMessage, }, ), "error", ); }, }); const deleteMutation = useMutation({ mutationFn: (id: string) => deleteClient(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["clients"] }); toast(t("msg.dev.clients.deleted", "앱이 삭제되었습니다.")); navigate("/clients"); }, onError: (err) => { const errorMessage = (err as AxiosError<{ error?: string }>).response?.data?.error ?? (err as Error)?.message; toast( t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", { error: errorMessage, }), "error", ); }, }); const handleDelete = () => { if ( clientId && window.confirm( t( "msg.dev.clients.delete_confirm", "정말로 이 앱을 삭제하시습니까? 이 작업은 되돌릴 수 없습니다.", ), ) ) { deleteMutation.mutate(clientId); } }; if ((isCreate && isLoadingDeveloperAccessGate) || (!isCreate && isLoading)) { return (
{t( "msg.dev.clients.general.loading", isCreate ? "Loading client creation..." : "Loading client...", )}
); } if (isCreate && !hasClientCreateAccess) { return (
navigate("/developer-requests")} />
); } 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 (
} title={ isCreate ? t("ui.dev.clients.general.title_create", "Create Client") : t("ui.dev.clients.general.title_edit", "Client Settings") } description={t( "ui.dev.clients.general.subtitle", "앱 정보, 권한 스코프, 보안 설정을 관리합니다.", )} />
{!isCreate && ( {status === "active" ? t("ui.common.status.active", "Active") : t("ui.common.status.inactive", "Inactive")} )}
{!isCreate && ( )} {isGeneralSettingsReadOnly && (
{t( "msg.dev.clients.general.read_only_hint", "이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다.", )}
)}
{/* 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", )} disabled={isGeneralSettingsReadOnly} />