From 22afe6654e59af88a00e812d2cd491fe7059892c Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 11 Jun 2026 15:02:52 +0900 Subject: [PATCH] =?UTF-8?q?offline=5Faccess=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20scope=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clients/ClientGeneralPage.claims.test.tsx | 36 +++ .../features/clients/ClientGeneralPage.tsx | 208 +++++++++++++++++- .../devfront-client-claims-cache.spec.ts | 64 ++++++ 3 files changed, 303 insertions(+), 5 deletions(-) diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index 4f5d4e02..547993c6 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -315,6 +315,42 @@ describe("ClientGeneralPage RP claims", () => { ); }); + it("shows supported scopes and custom claims without integrated offline_access from the add scope button", async () => { + const { container } = await renderPage(); + + const addScopeButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("Scope 추가")); + expect(addScopeButton).toBeDefined(); + + await act(async () => { + addScopeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(container.textContent).not.toContain("offline_access"); + expect(container.textContent).toContain("old_claim"); + + const customClaimButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("old_claim")); + expect(customClaimButton).toBeDefined(); + + await act(async () => { + customClaimButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + await flush(); + + const scopeInputs = Array.from( + container.querySelectorAll( + 'input[placeholder="e.g. profile"]', + ), + ); + expect(scopeInputs.some((input) => input.value === "old_claim")).toBe(true); + }); + it("blocks saving a number RP claim default value that is not numeric", async () => { const { container } = await renderPage(); diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 2809f34b..98f320c6 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -73,6 +73,13 @@ interface ScopeItem { locked?: boolean; } +interface ScopeCandidate { + id: string; + name: string; + description: string; + source: "standard" | "custom_claim" | "manual"; +} + type ClaimNamespace = "rp_claims"; type ClaimValueType = | "text" @@ -626,6 +633,7 @@ function ClientGeneralPage() { const [jwksUri, setJwksUri] = useState(""); const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false); + const [isScopePickerOpen, setIsScopePickerOpen] = useState(false); const [scopes, setScopes] = useState(() => [ { id: "1", @@ -712,6 +720,92 @@ function ClientGeneralPage() { [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", + }, + ], + [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; @@ -904,11 +998,28 @@ function ClientGeneralPage() { }; const addScope = () => { - const newId = String(Date.now()); - setScopes([ - ...scopes, - { id: newId, name: "", description: "", mandatory: false }, - ]); + 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 = ( @@ -1852,12 +1963,99 @@ function ClientGeneralPage() { onClick={addScope} className="gap-2" disabled={isGeneralSettingsReadOnly} + aria-expanded={isScopePickerOpen} > {t("ui.dev.clients.general.scopes.add", "Scope 추가")} + {isScopePickerOpen && ( +
+
+
+

+ {t( + "ui.dev.clients.general.scopes.picker_title", + "추가할 scope 선택", + )} +

+

+ {t( + "msg.dev.clients.general.scopes.picker_help", + "지원 scope와 Custom Claim key를 선택해 scope 목록에 추가합니다.", + )} +

+
+ +
+
+ {scopeCandidates.map((candidate) => { + const isManual = candidate.source === "manual"; + const isDuplicate = + candidate.name.trim() !== "" && + existingScopeNames.has(candidate.name.trim()); + return ( + + ); + })} +
+
+ )} + {isCreate && (