From 7145e703d786c5a2ecd89bd71653c255c248f26a Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 17 Jun 2026 17:34:36 +0900 Subject: [PATCH] =?UTF-8?q?=ED=81=B4=EB=A0=88=EC=9E=84=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EB=B0=8F=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clients/ClientGeneralPage.claims.test.tsx | 55 +- .../features/clients/ClientGeneralPage.tsx | 928 +++++++++--------- devfront/src/locales/en.toml | 1 + devfront/src/locales/ko.toml | 1 + devfront/src/locales/template.toml | 1 + 5 files changed, 540 insertions(+), 446 deletions(-) diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index 359c4d3a..c605560f 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -98,8 +98,8 @@ function makeClientDetail( }); } - return { - client: { + return { + client: { id: "client-claims", name: "Claims App", type: "private", @@ -334,6 +334,47 @@ describe("ClientGeneralPage RP claims", () => { ); }); + it("clears saved RP claims when custom claims are disabled", async () => { + const { container } = await renderPage(); + + const claimToggle = Array.from( + container.querySelectorAll('[role="switch"]'), + ).find((button) => + (button.getAttribute("aria-label") ?? "").includes("커스텀 클레임 사용"), + ); + expect(claimToggle).toBeDefined(); + expect(claimToggle?.getAttribute("aria-checked")).toBe("true"); + + await act(async () => { + claimToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(claimToggle?.getAttribute("aria-checked")).toBe("false"); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button.textContent?.includes("저장") || + button.textContent?.includes("Save"), + ); + expect(saveButton).toBeDefined(); + + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(updateClientMock).toHaveBeenCalledWith( + "client-claims", + expect.objectContaining({ + metadata: expect.objectContaining({ + id_token_claims_enabled: false, + id_token_claims: [], + }), + }), + ); + }); + it("preserves tenants scope mandatory state when tenant access restriction is off", async () => { fetchClientMock.mockResolvedValue( makeClientDetail("old_claim", { @@ -354,7 +395,7 @@ describe("ClientGeneralPage RP claims", () => { const tenantScopeRow = Array.from(container.querySelectorAll("tr")).find( (row) => - Array.from(row.querySelectorAll("input")).some( + Array.from(row.querySelectorAll("input")).some( (input) => (input as HTMLInputElement).value === "tenants", ), ); @@ -389,10 +430,10 @@ describe("ClientGeneralPage RP claims", () => { expect(updateClientMock).toHaveBeenCalledWith( "client-claims", expect.objectContaining({ - metadata: expect.objectContaining({ - tenant_access_restricted: false, - structured_scopes: expect.arrayContaining([ - expect.objectContaining({ + metadata: expect.objectContaining({ + tenant_access_restricted: false, + structured_scopes: expect.arrayContaining([ + expect.objectContaining({ name: "tenants", mandatory: false, locked: false, diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 55a04383..51d65218 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -633,6 +633,7 @@ function ClientGeneralPage() { const [allowedTenantIds, setAllowedTenantIds] = useState([]); const [autoLoginSupported, setAutoLoginSupported] = useState(false); const [autoLoginUrl, setAutoLoginUrl] = useState(""); + const [idTokenClaimsEnabled, setIdTokenClaimsEnabled] = useState(false); // Public Key Registration States const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] = @@ -956,7 +957,13 @@ function ClientGeneralPage() { ), ); } - setIdTokenClaims(readIdTokenClaimsMetadata(metadata)); + const savedIdTokenClaims = readIdTokenClaimsMetadata(metadata); + setIdTokenClaims(savedIdTokenClaims); + setIdTokenClaimsEnabled( + typeof metadata.id_token_claims_enabled === "boolean" + ? metadata.id_token_claims_enabled + : savedIdTokenClaims.length > 0, + ); }, [data, normalizeScopesForTenantAccess]); const securityProfile: SecurityProfile = @@ -1089,6 +1096,7 @@ function ClientGeneralPage() { }; const addIdTokenClaim = () => { + setIdTokenClaimsEnabled(true); setIdTokenClaims((current) => [ ...current, createIdTokenClaimItem(`claim-${Date.now()}`), @@ -1253,48 +1261,50 @@ function ClientGeneralPage() { } 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; - } + if (idTokenClaimsEnabled) { + 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 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); + const defaultValueError = claimDefaultValueValidationError(claim); + if (defaultValueError) { + claimValidationErrors.push(defaultValueError); + } } } validationErrors.push(...claimValidationErrors); const hasValidationErrors = validationErrors.length > 0; - const idTokenClaimPreview = buildIdTokenClaimsPreview( - normalizedIdTokenClaimItems, - ); + const idTokenClaimPreview = idTokenClaimsEnabled + ? buildIdTokenClaimsPreview(normalizedIdTokenClaimItems) + : []; const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2); const tenantOptions: TenantSummary[] = tenantData?.items ?? []; const selectedAllowedTenants = allowedTenantIds @@ -1435,7 +1445,8 @@ function ClientGeneralPage() { auto_login_supported: autoLoginSupported, auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined, structured_scopes: normalizedScopes, - id_token_claims: normalizedIdTokenClaims, + id_token_claims_enabled: idTokenClaimsEnabled, + id_token_claims: idTokenClaimsEnabled ? normalizedIdTokenClaims : [], token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, headless_login_enabled: headlessLoginEnabled, headless_token_endpoint_auth_method: headlessLoginEnabled @@ -1908,7 +1919,7 @@ function ClientGeneralPage() { + ) : null} + +
+
+

+ {idTokenClaimsEnabled + ? t("ui.common.enabled", "사용") + : t("ui.common.disabled", "사용 안 함")} +

+

+ {t( + "ui.dev.clients.general.id_token_claims.enabled", + "커스텀 클레임 사용", + )} +

+
+
- - -
-
- - - - - - {t( - "ui.dev.clients.general.id_token_claims.table.key", - "Claim Key", - )} - - - {t( - "ui.dev.clients.general.id_token_claims.table.namespace", - "Namespace", - )} - - - {t( - "ui.dev.clients.general.id_token_claims.table.value_type", - "Value Type", - )} - - - {t( - "ui.dev.clients.general.id_token_claims.table.nullable", - "Nullable", - )} - - - {t( - "ui.dev.clients.general.id_token_claims.table.read_user_allowed", - "User read", - )} - - - {t( - "ui.dev.clients.general.id_token_claims.table.write_user_allowed", - "User write", - )} - - - {t( - "ui.dev.clients.general.id_token_claims.table.default_value", - "Default Value", - )} - - - {t( - "ui.dev.clients.general.id_token_claims.table.delete", - "Delete", - )} - - - - - {idTokenClaims.length > 0 ? ( - idTokenClaims.map((claim) => { - const defaultValueError = - claimDefaultValueValidationError(claim); + {idTokenClaimsEnabled ? ( + +
+
+ + + + + + {t( + "ui.dev.clients.general.id_token_claims.table.key", + "Claim Key", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.namespace", + "Namespace", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.value_type", + "Value Type", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.nullable", + "Nullable", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.read_user_allowed", + "User read", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.write_user_allowed", + "User write", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.default_value", + "Default Value", + )} + + + {t( + "ui.dev.clients.general.id_token_claims.table.delete", + "Delete", + )} + + + + + {idTokenClaims.length > 0 ? ( + idTokenClaims.map((claim) => { + const defaultValueError = + claimDefaultValueValidationError(claim); - return ( - - - - updateIdTokenClaim( - claim.id, - "key", - e.target.value, - ) - } - className="h-8 font-mono text-xs" - placeholder={t( - "ui.dev.clients.general.id_token_claims.key_placeholder", - "e.g. locale", - )} - disabled={isGeneralSettingsReadOnly} - /> - - - - {t( - "ui.dev.clients.general.id_token_claims.namespace_rp_claims", - "rp_claims", - )} - - - - - - -
- - updateIdTokenClaim( - claim.id, - "nullable", - checked, - ) - } - aria-label={t( - "ui.dev.clients.general.id_token_claims.nullable_label", - "Nullable", - )} - disabled={isGeneralSettingsReadOnly} - /> -
-
- -
- - setIdTokenClaimPermissionAllowed( - claim.id, - "readPermission", - checked, - ) - } - aria-label={t( - "ui.dev.clients.general.id_token_claims.read_user_allowed_label", - "사용자 읽기 허용", - )} - disabled={isGeneralSettingsReadOnly} - /> -
-
- -
- - setIdTokenClaimPermissionAllowed( - claim.id, - "writePermission", - checked, - ) - } - aria-label={t( - "ui.dev.clients.general.id_token_claims.write_user_allowed_label", - "사용자 쓰기 허용", - )} - disabled={isGeneralSettingsReadOnly} - /> -
-
- - {claim.valueType === "array" || - claim.valueType === "object" ? ( -