From 95ac26734ad34cba17235c1bd185d3adf9176e31 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 17 Jun 2026 09:57:42 +0900 Subject: [PATCH 01/12] =?UTF-8?q?consent=20=EA=B6=8C=ED=95=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=EC=84=9C=20offline=5Faccess=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/auth/domain/consent_scope_policy.dart | 6 +++--- userfront/test/consent_scope_policy_test.dart | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/userfront/lib/features/auth/domain/consent_scope_policy.dart b/userfront/lib/features/auth/domain/consent_scope_policy.dart index 3bfa1e60..ad7b3dec 100644 --- a/userfront/lib/features/auth/domain/consent_scope_policy.dart +++ b/userfront/lib/features/auth/domain/consent_scope_policy.dart @@ -1,11 +1,11 @@ -bool isRefreshTokenScopeAlias(String scope) { +bool isOfflineScopeAlias(String scope) { final normalized = scope.trim().toLowerCase(); - return normalized == 'offline' || normalized == 'offline_access'; + return normalized == 'offline'; } List filterConsentScopes(Iterable scopes) { return scopes .map((scope) => scope.trim()) - .where((scope) => scope.isNotEmpty && !isRefreshTokenScopeAlias(scope)) + .where((scope) => scope.isNotEmpty && !isOfflineScopeAlias(scope)) .toList(growable: false); } diff --git a/userfront/test/consent_scope_policy_test.dart b/userfront/test/consent_scope_policy_test.dart index 909b60c1..4e873fcf 100644 --- a/userfront/test/consent_scope_policy_test.dart +++ b/userfront/test/consent_scope_policy_test.dart @@ -3,7 +3,7 @@ import 'package:userfront/features/auth/domain/consent_scope_policy.dart'; void main() { group('consent scope policy', () { - test('filters offline scope aliases from requested consent scopes', () { + test('keeps offline_access visible and filters only offline', () { expect( filterConsentScopes([ 'openid', @@ -12,14 +12,14 @@ void main() { 'offline_access', 'email', ]), - ['openid', 'profile', 'email'], + ['openid', 'profile', 'offline_access', 'email'], ); }); - test('detects refresh token scope aliases case-insensitively', () { - expect(isRefreshTokenScopeAlias('OFFLINE'), isTrue); - expect(isRefreshTokenScopeAlias(' offline_access '), isTrue); - expect(isRefreshTokenScopeAlias('profile'), isFalse); + test('detects offline scope alias case-insensitively', () { + expect(isOfflineScopeAlias('OFFLINE'), isTrue); + expect(isOfflineScopeAlias(' offline_access '), isFalse); + expect(isOfflineScopeAlias('profile'), isFalse); }); }); } From fd05c049d34a7f8f5f3cbd3a8648d4650887ee15 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 17 Jun 2026 10:50:37 +0900 Subject: [PATCH 02/12] =?UTF-8?q?tenants=20=EB=AA=85=EC=B9=AD=20=EB=B0=8F?= =?UTF-8?q?=20profile=20=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 5 ++- .../handler/auth_handler_consent_test.go | 10 ++--- .../auth_handler_dynamic_claims_test.go | 41 ++++++++++++------- .../internal/handler/client_tenant_access.go | 19 ++++++--- .../handler/client_tenant_access_test.go | 8 ++-- backend/internal/handler/dev_handler_test.go | 8 ++-- .../clients/ClientGeneralPage.claims.test.tsx | 24 +++++------ .../features/clients/ClientGeneralPage.tsx | 29 ++++++++----- devfront/src/locales/en.toml | 6 ++- devfront/src/locales/ko.toml | 6 ++- docs/rp-iam-integration-guide.md | 3 ++ 11 files changed, 98 insertions(+), 61 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index dae3e471..a54e91a6 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1111,6 +1111,9 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID if len(emails) > 0 { profile["emails"] = emails } + if phone := getString("phone_number"); phone != "" { + profile["phones"] = []string{phone} + } if len(profile) > 0 { claims["profile"] = profile } @@ -1364,7 +1367,7 @@ func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims m func tenantClaimScopeRequested(scopes []string) bool { for _, scope := range scopes { - if strings.EqualFold(strings.TrimSpace(scope), "tenant") { + if strings.EqualFold(strings.TrimSpace(scope), "tenants") || strings.EqualFold(strings.TrimSpace(scope), "tenant") { return true } } diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index dcb368b6..b0b7eb43 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -203,7 +203,7 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) { "allowed_tenants": []string{"tenant-allow"}, "structured_scopes": []map[string]any{ {"name": "openid", "mandatory": true}, - {"name": "tenant", "mandatory": true, "locked": true}, + {"name": "tenants", "mandatory": true, "locked": true}, {"name": "profile", "mandatory": false}, }, }, @@ -262,9 +262,9 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) { var body map[string]any json.NewDecoder(resp.Body).Decode(&body) - assert.Equal(t, []any{"openid", "tenant", "profile"}, body["requested_scope"]) + assert.Equal(t, []any{"openid", "tenants", "profile"}, body["requested_scope"]) scopeDetails := body["scope_details"].(map[string]any) - tenantDetail := scopeDetails["tenant"].(map[string]any) + tenantDetail := scopeDetails["tenants"].(map[string]any) assert.Equal(t, true, tenantDetail["mandatory"]) } @@ -448,7 +448,7 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { "allowed_tenants": []string{"tenant-abc"}, "structured_scopes": []map[string]any{ {"name": "openid", "mandatory": true}, - {"name": "tenant", "mandatory": true, "locked": true}, + {"name": "tenants", "mandatory": true, "locked": true}, {"name": "profile", "mandatory": false}, }, }, @@ -511,5 +511,5 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { resp, err := app.Test(req) assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes) + assert.Equal(t, []string{"openid", "tenants", "profile"}, capturedGrantScopes) } diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index f634f568..900ab75e 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -70,9 +70,10 @@ func TestWithRefreshTokenExpiryClaim_UsesHydraRefreshTokenTTL(t *testing.T) { func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { traits := map[string]any{ - "email": "user@baron.com", - "name": "홍길동", - "tenant_id": "primary-tenant-999", // Added primary tenant + "email": "user@baron.com", + "name": "홍길동", + "phone_number": "+821012345678", + "tenant_id": "primary-tenant-999", // Added primary tenant "tenant-1": map[string]any{ "department": "개발팀", "grade": "선임", @@ -91,6 +92,8 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { assert.Equal(t, "primary-tenant-999", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") @@ -105,6 +108,8 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { assert.Equal(t, "tenant-1", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") @@ -119,6 +124,8 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { assert.Equal(t, "tenant-2", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") @@ -131,17 +138,21 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { assert.Equal(t, "tenant-3", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") }) - t.Run("Tenant scope includes detailed tenant metadata", func(t *testing.T) { - claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenant"}, "tenant-1") + t.Run("Tenants scope includes detailed tenant metadata", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenants"}, "tenant-1") assert.Equal(t, "tenant-1", claims["tenant_id"]) assert.Equal(t, "개발팀", claims["department"]) assert.Equal(t, "선임", claims["grade"]) + profile := claims["profile"].(map[string]any) + assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.NotNil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") assert.Contains(t, claims["joined_tenants"], "tenant-2") @@ -190,7 +201,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-dynamic", - "requested_scope": []string{"openid", "profile", "tenant"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-123", "client": map[string]any{ "client_id": "client-app", @@ -260,7 +271,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-dynamic", - "grant_scope": []string{"openid", "profile", "tenant"}, + "grant_scope": []string{"openid", "profile", "tenants"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -290,7 +301,7 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-representative-tenant", - "requested_scope": []string{"openid", "profile", "tenant"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-representative", "client": map[string]any{ "client_id": "client-app", @@ -367,7 +378,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing. if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-hanmac-tenant-claim", - "requested_scope": []string{"openid", "profile", "tenant"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-hanmac", "client": map[string]any{ "client_id": "hanmac-rp", @@ -462,7 +473,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing. reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-hanmac-tenant-claim", - "grant_scope": []string{"openid", "profile", "tenant"}, + "grant_scope": []string{"openid", "profile", "tenants"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -574,7 +585,7 @@ func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-rp-profile", - "requested_scope": []string{"openid", "profile", "tenant"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-123", "client": map[string]any{ "client_id": "client-app", @@ -666,7 +677,7 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-skip-dynamic", - "requested_scope": []string{"openid", "profile", "tenant"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "skip": true, "subject": "user-456", "client": map[string]any{ @@ -861,7 +872,7 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-configured-claims", - "requested_scope": []string{"openid", "profile", "tenant"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-789", "client": map[string]any{ "client_id": "client-configured-claims", @@ -973,7 +984,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" { return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": "challenge-rp-user-claims", - "requested_scope": []string{"openid", "profile", "tenant"}, + "requested_scope": []string{"openid", "profile", "tenants"}, "subject": "user-rp-claims", "client": map[string]any{ "client_id": "client-rp-claims", @@ -1119,7 +1130,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) reqBody, _ := json.Marshal(map[string]any{ "consent_challenge": "challenge-rp-user-claims", - "grant_scope": []string{"openid", "profile", "tenant"}, + "grant_scope": []string{"openid", "profile", "tenants"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/handler/client_tenant_access.go b/backend/internal/handler/client_tenant_access.go index a5f9bbe3..7d2fb95c 100644 --- a/backend/internal/handler/client_tenant_access.go +++ b/backend/internal/handler/client_tenant_access.go @@ -463,7 +463,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { out := make([]string, 0, len(combined)) appendIfPresent := func(scope string) { - scope = strings.TrimSpace(scope) + scope = canonicalConsentScopeName(scope) if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { return } @@ -471,7 +471,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { return } for _, candidate := range combined { - if strings.TrimSpace(candidate) != scope { + if canonicalConsentScopeName(candidate) != scope { continue } seen[scope] = struct{}{} @@ -481,10 +481,10 @@ func normalizeScopesInConsentOrder(scopes []string) []string { } appendIfPresent("openid") - appendIfPresent("tenant") + appendIfPresent("tenants") for _, scope := range combined { - scope = strings.TrimSpace(scope) + scope = canonicalConsentScopeName(scope) if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } @@ -501,7 +501,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { func requiredClientScopes(client domain.HydraClient) []string { required := make([]string, 0, 4) if clientTenantAccessRestricted(client.Metadata) { - required = append(required, "tenant") + required = append(required, "tenants") } if client.Metadata == nil { @@ -535,3 +535,12 @@ func requiredClientScopes(client domain.HydraClient) []string { return normalizeScopesInConsentOrder(required) } + +func canonicalConsentScopeName(scope string) string { + switch strings.ToLower(strings.TrimSpace(scope)) { + case "tenant": + return "tenants" + default: + return strings.TrimSpace(scope) + } +} diff --git a/backend/internal/handler/client_tenant_access_test.go b/backend/internal/handler/client_tenant_access_test.go index 661cd631..af7ebf42 100644 --- a/backend/internal/handler/client_tenant_access_test.go +++ b/backend/internal/handler/client_tenant_access_test.go @@ -121,20 +121,20 @@ func TestCreateClient_RejectsTenantAccessWithoutAllowedTenants(t *testing.T) { assert.False(t, hydraCalled) } -func TestMergeRequestedScopesWithClientRequirements_AddsTenantScope(t *testing.T) { +func TestMergeRequestedScopesWithClientRequirements_AddsTenantsScope(t *testing.T) { client := domain.HydraClient{ Metadata: map[string]any{ "tenant_access_restricted": true, "structured_scopes": []map[string]any{ {"name": "openid", "mandatory": true}, - {"name": "tenant", "mandatory": true, "locked": true}, + {"name": "tenants", "mandatory": true, "locked": true}, {"name": "profile", "mandatory": false}, }, }, } merged := mergeRequestedScopesWithClientRequirements(client, []string{"openid", "profile"}) - assert.Equal(t, []string{"openid", "tenant", "profile"}, merged) + assert.Equal(t, []string{"openid", "tenants", "profile"}, merged) } func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAliases(t *testing.T) { @@ -154,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias []string{"openid", "offline", "profile", "offline_access"}, ) - assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged) + assert.Equal(t, []string{"openid", "tenants", "profile", "offline_access", "email"}, merged) } func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index fd67469a..a769b41d 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -864,7 +864,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) { }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, - "scope": "openid profile email tenant", + "scope": "openid profile email tenants", "token_endpoint_auth_method": "private_key_jwt", "metadata": map[string]any{ "status": "active", @@ -905,7 +905,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) { body, _ := json.Marshal(map[string]any{ "name": "App One Updated", - "scopes": []string{"openid", "profile", "email", "tenant"}, + "scopes": []string{"openid", "profile", "email", "tenants"}, "metadata": map[string]any{ "tenant_access_restricted": true, "allowed_tenants": []string{"tenant-1", "tenant-2"}, @@ -3009,7 +3009,7 @@ func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing. RedirectURIs: []string{"https://rp.example.com/callback"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, - Scope: "openid tenant profile email", + Scope: "openid tenants profile email", TokenEndpointAuthMethod: "none", Metadata: map[string]any{ "tenant_access_restricted": true, @@ -3093,7 +3093,7 @@ func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing. RedirectURIs: []string{"https://rp.example.com/callback"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, - Scope: "openid tenant profile email", + Scope: "openid tenants profile email", TokenEndpointAuthMethod: "none", Metadata: map[string]any{ "tenant_access_restricted": true, diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index 2fa975a5..359c4d3a 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -91,22 +91,22 @@ function makeClientDetail( if (includeTenantScope) { structuredScopes.push({ id: "2", - name: "tenant", + name: "tenants", description: "Tenant access", mandatory: tenantScopeMandatory, locked: tenantAccessRestricted, }); } - return { - client: { + return { + client: { id: "client-claims", name: "Claims App", type: "private", status: "active", redirectUris: ["https://rp.example.com/callback"], scopes: includeTenantScope - ? ["openid", "tenant", "profile"] + ? ["openid", "tenants", "profile"] : ["openid", "profile"], tokenEndpointAuthMethod: "client_secret_basic", metadata: { @@ -334,7 +334,7 @@ describe("ClientGeneralPage RP claims", () => { ); }); - it("preserves tenant scope mandatory state when tenant access restriction is off", async () => { + it("preserves tenants scope mandatory state when tenant access restriction is off", async () => { fetchClientMock.mockResolvedValue( makeClientDetail("old_claim", { includeTenantScope: true, @@ -354,8 +354,8 @@ describe("ClientGeneralPage RP claims", () => { const tenantScopeRow = Array.from(container.querySelectorAll("tr")).find( (row) => - Array.from(row.querySelectorAll("input")).some( - (input) => (input as HTMLInputElement).value === "tenant", + Array.from(row.querySelectorAll("input")).some( + (input) => (input as HTMLInputElement).value === "tenants", ), ); @@ -389,11 +389,11 @@ describe("ClientGeneralPage RP claims", () => { expect(updateClientMock).toHaveBeenCalledWith( "client-claims", expect.objectContaining({ - metadata: expect.objectContaining({ - tenant_access_restricted: false, - structured_scopes: expect.arrayContaining([ - expect.objectContaining({ - name: "tenant", + 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 3ab2cd64..e4de8e18 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -650,8 +650,8 @@ function ClientGeneralPage() { }, { id: "2", - name: "tenant", - description: t("msg.dev.clients.scopes.tenant", "소속 테넌트 정보 접근"), + name: "tenants", + description: t("msg.dev.clients.scopes.tenants", "소속 테넌트 정보 접근"), mandatory: false, }, { @@ -675,14 +675,14 @@ function ClientGeneralPage() { ); const tenantScopeDescription = t( - "msg.dev.clients.scopes.tenant", + "msg.dev.clients.scopes.tenants", "소속 테넌트 정보 접근", ); const buildTenantScope = useCallback( (id: string): ScopeItem => ({ id, - name: "tenant", + name: "tenants", description: tenantScopeDescription, mandatory: true, locked: true, @@ -693,12 +693,15 @@ function ClientGeneralPage() { const normalizeScopesForTenantAccess = useCallback( (nextScopes: ScopeItem[], restricted: boolean): ScopeItem[] => { const normalized = nextScopes.map((scope) => { - if (scope.name.trim() !== "tenant") { + const scopeName = scope.name.trim(); + if (scopeName !== "tenants" && scopeName !== "tenant") { return scope; } + const canonicalName = "tenants"; if (restricted) { return { ...scope, + name: canonicalName, description: scope.description || tenantScopeDescription, mandatory: true, locked: true, @@ -706,6 +709,7 @@ function ClientGeneralPage() { } return { ...scope, + name: canonicalName, description: scope.description || tenantScopeDescription, locked: false, }; @@ -713,20 +717,23 @@ function ClientGeneralPage() { if ( restricted && - !normalized.some((scope) => scope.name.trim() === "tenant") + !normalized.some( + (scope) => scope.name.trim() === "tenants" || scope.name.trim() === "tenant", + ) ) { - normalized.push(buildTenantScope(`tenant-${Date.now()}`)); + normalized.push(buildTenantScope(`tenants-${Date.now()}`)); } const openidScopes = normalized.filter( (scope) => scope.name.trim() === "openid", ); const tenantScopes = normalized.filter( - (scope) => scope.name.trim() === "tenant", + (scope) => + scope.name.trim() === "tenants" || scope.name.trim() === "tenant", ); const remainingScopes = normalized.filter((scope) => { const name = scope.name.trim(); - return name !== "openid" && name !== "tenant"; + return name !== "openid" && name !== "tenants" && name !== "tenant"; }); return [...openidScopes, ...tenantScopes, ...remainingScopes]; @@ -762,7 +769,7 @@ function ClientGeneralPage() { }, { id: "standard-tenant", - name: "tenant", + name: "tenants", description: tenantScopeDescription, source: "standard", }, @@ -2389,7 +2396,7 @@ function ClientGeneralPage() {

{t( "ui.dev.clients.general.tenant_access.hint", - "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.", + "제한을 켜면 tenants 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.", )}

diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 4911ef5c..751463a4 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -518,6 +518,8 @@ description = "Manage OIDC applications, authentication methods, redirect URIs, email = "Email" openid = "Openid" profile = "Profile" +tenant = "Tenant access" +tenants = "Tenants access" [msg.dev.dashboard] access_denied = "The dashboard is available only to users with developer access." @@ -1622,7 +1624,7 @@ description = "Scope Description" mandatory = "Mandatory" name = "Scope Name" delete = "Delete" -tenant = "Tenant" +tenants = "Tenants" [ui.dev.clients.general.tenant_access] title = "Tenant access restriction" @@ -1633,7 +1635,7 @@ search_placeholder = "Search by tenant name or slug" selected_title = "Allowed tenants" selected_empty = "No tenants selected yet." empty = "No tenants match your search." -hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant." +hint = "Turning this on adds the tenants scope automatically and requires at least one allowed tenant." autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list." validation_required = "Select at least one allowed tenant when tenant access restriction is enabled." picker_title = "Select tenant" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 53e30423..72576b64 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -518,6 +518,8 @@ description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행 email = "이메일 주소 접근" openid = "OIDC 인증 필수 스코프" profile = "기본 프로필 정보 접근" +tenant = "테넌트 접근" +tenants = "테넌트 접근" [msg.dev.dashboard] access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다." @@ -1621,7 +1623,7 @@ description = "설명" mandatory = "필수" name = "스코프 이름" delete = "삭제" -tenant = "테넌트" +tenants = "테넌트들" [ui.dev.clients.general.tenant_access] title = "테넌트 접근 제한" @@ -1632,7 +1634,7 @@ search_placeholder = "테넌트 이름 또는 슬러그로 검색" selected_title = "허용 테넌트" selected_empty = "아직 선택된 테넌트가 없습니다." empty = "검색 결과가 없습니다." -hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다." +hint = "제한을 켜면 tenants 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다." autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다." validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다." picker_title = "테넌트 선택" diff --git a/docs/rp-iam-integration-guide.md b/docs/rp-iam-integration-guide.md index 6c712e25..5574aaa9 100644 --- a/docs/rp-iam-integration-guide.md +++ b/docs/rp-iam-integration-guide.md @@ -241,6 +241,9 @@ Baron은 기본적으로 대표소속 tenant와 전체 소속 tenant 목록을 "emails": [ "hanmac-user@example.com" ], + "phones": [ + "+821012345678" + ], "names": { "name": "한맥 사용자" } From efab2a729159c40c803d171a7a1e3c087d69e0f3 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 17 Jun 2026 11:50:34 +0900 Subject: [PATCH 03/12] =?UTF-8?q?profile=20=ED=81=B4=EB=A0=88=EC=9E=84=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 93 ++++++++++++++----- .../auth_handler_dynamic_claims_test.go | 39 ++++++-- docs/rp-iam-integration-guide.md | 12 ++- 3 files changed, 111 insertions(+), 33 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index a54e91a6..f0294cd0 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1040,7 +1040,7 @@ func normalizePhoneForLoginID(phone string) string { return domain.NormalizePhoneNumber(phone) } -func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any { +func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string, profileStatus string) map[string]any { claims := map[string]any{} if traits == nil { return claims @@ -1089,31 +1089,27 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID if _, ok := scopeSet["profile"]; ok { profile := map[string]any{} - names := map[string]any{} - for _, key := range []string{ - "name", - "displayname", - "preferred_username", - "given_name", - "family_name", - "middle_name", - "nickname", - } { - if value := getString(key); value != "" { - names[key] = value - } + displayProfileName := displayName + if displayProfileName != "" { + profile["name"] = displayProfileName } - if len(names) > 0 { - profile["names"] = names + if primaryEmail != "" { + profile["email"] = primaryEmail } - - emails := collectEmailList(traits, primaryEmail) - if len(emails) > 0 { - profile["emails"] = emails + if secondaryEmails := collectStringValues(traits, "sub_email", "secondary_emails", "additional_emails", "aliasEmails", "worksmobileAliasEmails"); len(secondaryEmails) > 0 { + profile["secondary_emails"] = secondaryEmails } if phone := getString("phone_number"); phone != "" { profile["phones"] = []string{phone} } + if employeeID := getString("employee_id"); employeeID != "" { + profile["employee_id"] = employeeID + } + if trimmedStatus := strings.TrimSpace(profileStatus); trimmedStatus != "" { + if normalizedStatus := strings.TrimSpace(domain.NormalizeUserStatus(trimmedStatus)); normalizedStatus != "" { + profile["status"] = normalizedStatus + } + } if len(profile) > 0 { claims["profile"] = profile } @@ -1218,13 +1214,63 @@ func withRefreshTokenExpiryClaim(claims map[string]any, issuedAt time.Time) map[ return claims } -func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any { - claims := buildOidcClaimsFromTraits(traits, scopes, tenantID) +func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string, profileStatus string) map[string]any { + claims := buildOidcClaimsFromTraits(traits, scopes, tenantID, profileStatus) claims = applyConfiguredIDTokenClaims(claims, client.Metadata) claims = withRefreshTokenExpiryClaim(claims, time.Now()) return withOidcSessionMetadata(claims, sessionID) } +func collectStringValues(traits map[string]any, keys ...string) []string { + values := make([]string, 0) + seen := make(map[string]struct{}) + add := func(raw string) { + value := strings.TrimSpace(raw) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + values = append(values, value) + } + for _, key := range keys { + raw, ok := traits[key] + if !ok || raw == nil { + continue + } + switch value := raw.(type) { + case string: + add(value) + case []string: + for _, item := range value { + add(item) + } + case []any: + for _, item := range value { + add(fmt.Sprint(item)) + } + } + } + return values +} + +func (h *AuthHandler) resolveProfileStatus(ctx context.Context, subject string) string { + if h == nil || h.UserRepo == nil { + return "" + } + subject = strings.TrimSpace(subject) + if subject == "" { + return "" + } + user, err := h.UserRepo.FindByID(ctx, subject) + if err != nil || user == nil { + return "" + } + return domain.NormalizeUserStatus(user.Status) +} + func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) map[string]any { if baseClaims == nil { baseClaims = map[string]any{} @@ -6298,6 +6344,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { consentRequest.RequestedScope, representativeTenantIDFromTraits(identity.Traits), currentSessionID, + h.resolveProfileStatus(c.Context(), consentRequest.Subject), ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) @@ -6336,6 +6383,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { consentRequest.RequestedScope, representativeTenantIDFromTraits(identity.Traits), currentSessionID, + h.resolveProfileStatus(c.Context(), consentRequest.Subject), ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) @@ -6527,6 +6575,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { consentRequest.RequestedScope, representativeTenantIDFromTraits(identity.Traits), currentSessionID, + h.resolveProfileStatus(c.Context(), consentRequest.Subject), ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index 900ab75e..2ee5a025 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -73,6 +73,8 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { "email": "user@baron.com", "name": "홍길동", "phone_number": "+821012345678", + "employee_id": "EMP-001", + "sub_email": []any{"alias1@baron.com", "alias2@baron.com"}, "tenant_id": "primary-tenant-999", // Added primary tenant "tenant-1": map[string]any{ "department": "개발팀", @@ -86,13 +88,18 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { scopes := []string{"openid", "profile"} t.Run("No tenantID", func(t *testing.T) { - claims := buildOidcClaimsFromTraits(traits, scopes, "") + claims := buildOidcClaimsFromTraits(traits, scopes, "", "leave_of_absence") assert.Equal(t, "user@baron.com", claims["email"]) assert.Equal(t, "홍길동", claims["name"]) assert.Equal(t, "primary-tenant-999", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) @@ -102,13 +109,18 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { }) t.Run("With tenant-1", func(t *testing.T) { - claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1") + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1", "leave_of_absence") assert.Equal(t, "user@baron.com", claims["email"]) assert.Equal(t, "홍길동", claims["name"]) assert.Equal(t, "tenant-1", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) @@ -118,13 +130,18 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { }) t.Run("With tenant-2", func(t *testing.T) { - claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2") + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2", "leave_of_absence") assert.Equal(t, "user@baron.com", claims["email"]) assert.Equal(t, "홍길동", claims["name"]) assert.Equal(t, "tenant-2", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) @@ -132,13 +149,18 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { }) t.Run("With non-existent tenant", func(t *testing.T) { - claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3") + claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3", "leave_of_absence") assert.Equal(t, "user@baron.com", claims["email"]) assert.Equal(t, "홍길동", claims["name"]) assert.Equal(t, "tenant-3", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.Nil(t, claims["tenants"]) @@ -147,11 +169,16 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { }) t.Run("Tenants scope includes detailed tenant metadata", func(t *testing.T) { - claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenants"}, "tenant-1") + claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenants"}, "tenant-1", "leave_of_absence") assert.Equal(t, "tenant-1", claims["tenant_id"]) assert.Equal(t, "개발팀", claims["department"]) assert.Equal(t, "선임", claims["grade"]) profile := claims["profile"].(map[string]any) + assert.Equal(t, "홍길동", profile["name"]) + assert.Equal(t, "user@baron.com", profile["email"]) + assert.Equal(t, "EMP-001", profile["employee_id"]) + assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"]) + assert.Equal(t, "temporary_leave", profile["status"]) assert.Equal(t, []string{"+821012345678"}, profile["phones"]) assert.NotNil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") @@ -856,7 +883,7 @@ func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) { "writePermission": "admin_only", }, }, - }, []string{"openid", "profile", "email"}, "") + }, []string{"openid", "profile", "email"}, "", "") assert.Equal(t, "2026-06-09", claims["contract_date"]) assert.Equal(t, "2026-06-09T09:30:00+09:00", claims["approved_at"]) diff --git a/docs/rp-iam-integration-guide.md b/docs/rp-iam-integration-guide.md index 5574aaa9..a9c8a8ae 100644 --- a/docs/rp-iam-integration-guide.md +++ b/docs/rp-iam-integration-guide.md @@ -238,15 +238,17 @@ Baron은 기본적으로 대표소속 tenant와 전체 소속 tenant 목록을 } }, "profile": { - "emails": [ - "hanmac-user@example.com" + "email": "hanmac-user@example.com", + "secondary_emails": [ + "alias1@hanmaceng.co.kr", + "alias2@hanmaceng.co.kr" ], "phones": [ "+821012345678" ], - "names": { - "name": "한맥 사용자" - } + "name": "한맥 사용자", + "employee_id": "EMP-001", + "status": "temporary_leave" } } ``` From 8b183cab613f8c4fb352a927bca176cf0c0bfbfa Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 17 Jun 2026 13:58:07 +0900 Subject: [PATCH 04/12] =?UTF-8?q?scope=20=EC=84=A4=EB=AA=85=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientGeneralPage.tsx | 29 ++++++++++++++----- devfront/src/locales/en.toml | 13 +++++---- devfront/src/locales/ko.toml | 13 +++++---- devfront/src/locales/template.toml | 3 ++ 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index e4de8e18..ccdfdf18 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -645,25 +645,37 @@ function ClientGeneralPage() { { id: "1", name: "openid", - description: t("msg.dev.clients.scopes.openid", "OIDC 인증 필수 스코프"), + description: t( + "msg.dev.clients.scopes.openid", + "OIDC 로그인에 필요한 기본 scope", + ), mandatory: true, }, { id: "2", name: "tenants", - description: t("msg.dev.clients.scopes.tenants", "소속 테넌트 정보 접근"), + description: t( + "msg.dev.clients.scopes.tenants", + "tenant_id, joined_tenants, tenants 상세 및 root/부모 테넌트 접근", + ), mandatory: false, }, { id: "3", name: "profile", - description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"), + description: t( + "msg.dev.clients.scopes.profile", + "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근", + ), mandatory: false, }, { id: "4", name: "email", - description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"), + description: t( + "msg.dev.clients.scopes.email", + "top-level email과 profile.email", + ), mandatory: false, }, ]); @@ -748,7 +760,7 @@ function ClientGeneralPage() { name: "openid", description: t( "msg.dev.clients.scopes.openid", - "OIDC 인증 필수 스코프", + "OIDC 로그인에 필요한 기본 scope", ), source: "standard", }, @@ -757,14 +769,17 @@ function ClientGeneralPage() { name: "profile", description: t( "msg.dev.clients.scopes.profile", - "기본 프로필 정보 접근", + "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근", ), source: "standard", }, { id: "standard-email", name: "email", - description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"), + description: t( + "msg.dev.clients.scopes.email", + "top-level email과 profile.email", + ), source: "standard", }, { diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 751463a4..a1b2249f 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -515,11 +515,11 @@ subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs." [msg.dev.clients.scopes] -email = "Email" -openid = "Openid" -profile = "Profile" +email = "User email information" +openid = "Base scope required for OIDC login" +profile = "User profile data access: name, email, phones, secondary_emails, employee_id, and status" tenant = "Tenant access" -tenants = "Tenants access" +tenants = "Tenant root/parent chain and joined_tenants access" [msg.dev.dashboard] access_denied = "The dashboard is available only to users with developer access." @@ -1613,6 +1613,7 @@ session_required_info = "Show SID Claim Required help" add = "Scope Add" description_placeholder = "Description Placeholder" name_placeholder = "e.g. profile" +subtitle = "Review the permissions this client can request." title = "Scopes" offline_access_title = "offline_access scope is required when using refresh tokens." offline_access_toggle = "Show details" @@ -1797,9 +1798,9 @@ subtitle = "Applications" title = "RP registry" [ui.dev.clients.scopes] -email = "Email" +email = "User email information" openid = "Openid" -profile = "Profile" +profile = "User profile data access: name, email, phones, secondary_emails, employee_id, and status" [ui.dev.clients.table] actions = "Actions" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 72576b64..5cf8fe2d 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -515,11 +515,11 @@ subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다." [msg.dev.clients.scopes] -email = "이메일 주소 접근" -openid = "OIDC 인증 필수 스코프" -profile = "기본 프로필 정보 접근" +email = "사용자 이메일 정보" +openid = "OIDC 로그인에 필요한 기본 scope" +profile = "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근" tenant = "테넌트 접근" -tenants = "테넌트 접근" +tenants = "소속 테넌트 정보 접근" [msg.dev.dashboard] access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다." @@ -1612,6 +1612,7 @@ session_required_info = "SID Claim Required 설명 보기" add = "스코프 추가" description_placeholder = "권한에 대한 설명" name_placeholder = "e.g. profile" +subtitle = "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다." title = "스코프" offline_access_title = "Refresh token 사용 시 offline_access scope가 필요합니다." offline_access_toggle = "상세 안내 보기" @@ -1796,9 +1797,9 @@ subtitle = "연동 앱" title = "RP registry" [ui.dev.clients.scopes] -email = "이메일 주소 접근" +email = "사용자 이메일 정보" openid = "OIDC 인증 필수 스코프" -profile = "기본 프로필 정보 접근" +profile = "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근" [ui.dev.clients.table] actions = "액션" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 9c479768..9b62d1d9 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -556,6 +556,8 @@ description = "" email = "" openid = "" profile = "" +tenant = "" +tenants = "" [msg.dev.dashboard] access_denied = "" @@ -1660,6 +1662,7 @@ session_required_info = "" add = "" description_placeholder = "" name_placeholder = "" +subtitle = "" title = "" offline_access_title = "" offline_access_toggle = "" From c308d0a7d4e2482f9b223d095a2fa1d6b469992d Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 17 Jun 2026 15:49:54 +0900 Subject: [PATCH 05/12] =?UTF-8?q?devfront=20RP=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=91=9C=20=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/core/i18n/loader.ts | 19 +- .../features/clients/ClientGeneralPage.tsx | 885 +++++++++--------- .../clients/components/SettingsTable.tsx | 130 +++ devfront/src/lib/i18n.test.ts | 48 + devfront/src/locales/en.toml | 5 + devfront/src/locales/ko.toml | 12 +- devfront/src/locales/template.toml | 5 + 7 files changed, 656 insertions(+), 448 deletions(-) create mode 100644 devfront/src/features/clients/components/SettingsTable.tsx diff --git a/common/core/i18n/loader.ts b/common/core/i18n/loader.ts index 7396445b..1ae75121 100644 --- a/common/core/i18n/loader.ts +++ b/common/core/i18n/loader.ts @@ -29,6 +29,23 @@ function mergeTomlObjects(base: TomlObject, override: TomlObject): TomlObject { return result; } +function setTomlValue( + target: TomlObject, + path: string[], + value: TomlValue, +): void { + let cursor: TomlObject = target; + for (let index = 0; index < path.length - 1; index += 1) { + const key = path[index]; + const current = cursor[key]; + if (!current || typeof current === "string") { + cursor[key] = {}; + } + cursor = cursor[key] as TomlObject; + } + cursor[path[path.length - 1]] = value; +} + function isSupportedLocale(value: string): value is Locale { return (SUPPORTED_LOCALES as readonly string[]).includes(value); } @@ -82,7 +99,7 @@ function parseToml(raw: string): TomlObject { cursor = cursor[section] as TomlObject; } - cursor[key] = value; + setTomlValue(cursor, key.split("."), value); } return root; diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index ccdfdf18..ca202477 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -30,14 +30,6 @@ 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 { @@ -64,6 +56,16 @@ import { cn } from "../../lib/utils"; import { fetchMe, type UserProfile } from "../auth/authApi"; import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { ClientDetailTabs } from "./ClientDetailTabs"; +import { + SettingsTable, + SettingsTableBody, + SettingsTableCell, + SettingsTableEmptyState, + SettingsTableHead, + SettingsTableHeader, + SettingsTableRow, + SettingsTableShell, +} from "./components/SettingsTable"; import { TenantAccessPicker } from "./components/TenantAccessPicker"; import { claimDateTimeValueToInputString, @@ -730,7 +732,8 @@ function ClientGeneralPage() { if ( restricted && !normalized.some( - (scope) => scope.name.trim() === "tenants" || scope.name.trim() === "tenant", + (scope) => + scope.name.trim() === "tenants" || scope.name.trim() === "tenant", ) ) { normalized.push(buildTenantScope(`tenants-${Date.now()}`)); @@ -2284,110 +2287,105 @@ function ClientGeneralPage() { -
- - + + + - - - - + - - - {scopes.map((s) => ( - - - - - - - ))} - {scopes.length === 0 && ( - - - + + + + updateScope(s.id, "description", e.target.value) + } + className="h-8 text-xs" + placeholder={t( + "ui.dev.clients.general.scopes.description_placeholder", + "권한에 대한 설명", + )} + disabled={s.locked || isGeneralSettingsReadOnly} + /> + + +
+ + updateScope(s.id, "mandatory", checked) + } + disabled={s.locked || isGeneralSettingsReadOnly} + /> +
+
+ + + + + )) + ) : ( + + {t( + "msg.dev.clients.general.scopes.empty", + "등록된 스코프가 없습니다.", + )} + )} - -
+ {t( "ui.dev.clients.general.scopes.table.name", "Scope Name", )} - + + {t( "ui.dev.clients.general.scopes.table.description", "Description", )} - + + {t( "ui.dev.clients.general.scopes.table.mandatory", "Mandatory", )} - + + {t("ui.dev.clients.general.scopes.table.delete", "Delete")} -
- - 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", - )} - disabled={s.locked || isGeneralSettingsReadOnly} - /> - - - updateScope(s.id, "description", e.target.value) - } - className="h-8 text-xs" - placeholder={t( - "ui.dev.clients.general.scopes.description_placeholder", - "권한에 대한 설명", - )} - disabled={s.locked || isGeneralSettingsReadOnly} - /> - -
- - updateScope(s.id, "mandatory", checked) + + + {scopes.length > 0 ? ( + scopes.map((s) => ( + + + + 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", + )} disabled={s.locked || isGeneralSettingsReadOnly} /> -
-
- -
- {t( - "msg.dev.clients.general.scopes.empty", - "등록된 스코프가 없습니다.", - )} -
-
+ + + @@ -2472,54 +2470,56 @@ function ClientGeneralPage() { "허용 테넌트", )} -
- {allowedTenantIds.length > 0 ? ( -
- - - - - {t( - "ui.dev.clients.general.tenant_access.table.name", - "테넌트명", - )} - - - {t( - "ui.dev.clients.general.tenant_access.table.slug", - "슬러그", - )} - - - {t( - "ui.dev.clients.general.tenant_access.table.id", - "테넌트 ID", - )} - - - {t( - "ui.dev.clients.general.tenant_access.table.actions", - "작업", - )} - - - - + + + + + + {t( + "ui.dev.clients.general.tenant_access.table.name", + "테넌트명", + )} + + + {t( + "ui.dev.clients.general.tenant_access.table.slug", + "슬러그", + )} + + + {t( + "ui.dev.clients.general.tenant_access.table.id", + "테넌트 ID", + )} + + + {t( + "ui.dev.clients.general.tenant_access.table.actions", + "작업", + )} + + + + + {selectedAllowedTenants.length > 0 ? ( + <> {selectedAllowedTenants.map((tenant) => ( - - - {tenant.name} - - + + + {tenant.name} + + + {tenant.slug || "-"} - - + + {tenant.id} - - + +
-
-
+ + ))} {allowedTenantIds .filter( @@ -2553,20 +2553,22 @@ function ClientGeneralPage() { ), ) .map((tenantId) => ( - - - {tenantId} - - + + + {tenantId} + + + - - - + + {tenantId} - - + +
-
-
+ + ))} - -
-
- ) : ( -
- {t( - "ui.dev.clients.general.tenant_access.selected_empty", - "아직 선택된 테넌트가 없습니다.", + + ) : ( + + {t( + "ui.dev.clients.general.tenant_access.selected_empty", + "아직 선택된 테넌트가 없습니다.", + )} + )} -
- )} -
+ + + ) : null} @@ -2638,275 +2640,234 @@ function ClientGeneralPage() { -
+
-
- - + + + - - - - - - - - + - - - {idTokenClaims.map((claim) => { - const defaultValueError = - claimDefaultValueValidationError(claim); + + + {idTokenClaims.length > 0 ? ( + idTokenClaims.map((claim) => { + const defaultValueError = + claimDefaultValueValidationError(claim); - return ( - - - - - - - -
+ {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", )} -
- - updateIdTokenClaim( - claim.id, - "key", - e.target.value, - ) - } - className="h-9 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" ? ( -