diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 5bacafda..d67cf863 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -132,6 +132,7 @@ jobs: global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)' front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)' i18n_shared='^(locales/|common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)' + react_i18n='^(adminfront/src/locales/|devfront/src/locales/|orgfront/src/locales/)' backend=false userfront=false @@ -154,7 +155,7 @@ jobs: if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi lint=false - if [ "$backend" = true ] || [ "$userfront" = true ] || [ "$adminfront" = true ] || [ "$devfront" = true ] || [ "$orgfront" = true ] || matches "$i18n_shared"; then + if [ "$backend" = true ] || [ "$userfront" = true ] || matches "$global|$i18n_shared|$react_i18n"; then lint=true fi @@ -213,42 +214,6 @@ jobs: channel: "stable" cache: true - - name: Install adminfront dependencies - run: | - cd adminfront - npx pnpm install -C ../common --no-frozen-lockfile - npx pnpm install --no-frozen-lockfile - - - name: Biome check adminfront (lint + format) - run: | - cd adminfront - npx biome check . --formatter-enabled=false --assist-enabled=false - npx biome check . --linter-enabled=false --assist-enabled=false - - - name: Install devfront dependencies - run: | - cd devfront - npx pnpm install -C ../common --no-frozen-lockfile - npx pnpm install --no-frozen-lockfile - - - name: Biome check devfront (lint + format) - run: | - cd devfront - npx biome check . --formatter-enabled=false --assist-enabled=false - npx biome check . --linter-enabled=false --assist-enabled=false - - - name: Install orgfront dependencies - run: | - cd orgfront - npx pnpm install -C ../common --no-frozen-lockfile - npx pnpm install --no-frozen-lockfile - - - name: Biome check orgfront (lint + format) - run: | - cd orgfront - npx biome check . --formatter-enabled=false --assist-enabled=false - npx biome check . --linter-enabled=false --assist-enabled=false - - name: Lint Go backend run: | docker run --rm \ @@ -879,7 +844,7 @@ jobs: adminfront-vitest-coverage: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} runs-on: ubuntu-latest steps: @@ -1010,7 +975,7 @@ jobs: devfront-vitest-coverage: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} runs-on: ubuntu-latest steps: @@ -1141,7 +1106,7 @@ jobs: orgfront-vitest-coverage: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} runs-on: ubuntu-latest steps: @@ -1272,7 +1237,7 @@ jobs: adminfront-tests: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }} runs-on: ubuntu-latest timeout-minutes: 30 @@ -1367,7 +1332,7 @@ jobs: devfront-tests: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }} runs-on: ubuntu-latest steps: @@ -1550,7 +1515,7 @@ jobs: orgfront-tests: needs: - changes - - lint + - biome-check if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }} runs-on: ubuntu-latest steps: diff --git a/adminfront/src/components/ui/use-toast.ts b/adminfront/src/components/ui/use-toast.ts index 402ed87c..aaea4c9a 100644 --- a/adminfront/src/components/ui/use-toast.ts +++ b/adminfront/src/components/ui/use-toast.ts @@ -18,6 +18,11 @@ const notify = () => { }; const toastBase = (message: string, type: ToastType = "success") => { + if ( + toasts.some((toast) => toast.message === message && toast.type === type) + ) { + return; + } const id = Math.random().toString(36).substring(2, 9); toasts = [...toasts, { id, message, type }]; notify(); diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx index 31a5652f..499e0390 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx @@ -656,7 +656,7 @@ export function TenantWorksmobilePage() { actionDisabled={isCreatingUsers || createSelectedMutation.isPending} updateActionLabel="선택 구성원 업데이트 적용" onCreateSelected={(ids, initialPassword) => - createSelectedMutation.mutate({ + createSelectedMutation.mutateAsync({ resourceKind: "users", ids, initialPassword, @@ -1031,7 +1031,7 @@ function ComparisonTable({ actionLabel: string; updateActionLabel?: string; actionDisabled: boolean; - onCreateSelected: (ids: string[], initialPassword?: string) => void; + onCreateSelected: (ids: string[], initialPassword?: string) => unknown; onUpdateSelected?: (ids: string[]) => void; onRunSelected?: (actionIds: string[], deleteIds: string[]) => void; deleteActionLabel?: string; @@ -1222,13 +1222,17 @@ function ComparisonTable({ onUpdateSelected(selectedUpdateUserIds); }; - const confirmInitialPassword = () => { + const confirmInitialPassword = async () => { const password = initialPassword.trim(); if (!password) { toast.error("WORKS 초기 비밀번호를 입력해 주세요."); return; } - onCreateSelected(pendingInitialPasswordIds, password); + try { + await onCreateSelected(pendingInitialPasswordIds, password); + } catch { + return; + } setInitialPasswordOpen(false); setInitialPassword(""); setPendingInitialPasswordIds([]); @@ -1383,7 +1387,11 @@ function ComparisonTable({ > 취소 - diff --git a/adminfront/src/features/tenants/utils/protectedTenants.test.ts b/adminfront/src/features/tenants/utils/protectedTenants.test.ts index dde3b910..622d94aa 100644 --- a/adminfront/src/features/tenants/utils/protectedTenants.test.ts +++ b/adminfront/src/features/tenants/utils/protectedTenants.test.ts @@ -1,12 +1,25 @@ import { describe, expect, it } from "vitest"; -import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants"; +import { getSeedTenantIds, isSeedTenant } from "./protectedTenants"; describe("protectedTenants", () => { - it("marks tenants from seed-tenant.csv as protected", () => { - expect(getSeedTenantSlugs()).toEqual( - expect.arrayContaining(["hanmac-family", "personal"]), + it("marks tenants from seed-tenant.csv as protected by UUID", () => { + expect(getSeedTenantIds()).toEqual( + expect.arrayContaining([ + "038326b6-954a-48a7-a85f-efd83f62b82a", + "5a03efd2-e62f-4243-800d-58334bf48b2f", + "9607eb7b-04d2-42ab-80fe-780fe21c7e8f", + ]), ); - expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true); - expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false); + expect( + isSeedTenant({ + id: "5a03efd2-e62f-4243-800d-58334bf48b2f", + }), + ).toBe(true); + expect( + isSeedTenant({ + id: "5A03EFD2-E62F-4243-800D-58334BF48B2F", + }), + ).toBe(true); + expect(isSeedTenant({ id: "normal-tenant" })).toBe(false); }); }); diff --git a/adminfront/src/features/tenants/utils/protectedTenants.ts b/adminfront/src/features/tenants/utils/protectedTenants.ts index a9c6c42d..1f25fd82 100644 --- a/adminfront/src/features/tenants/utils/protectedTenants.ts +++ b/adminfront/src/features/tenants/utils/protectedTenants.ts @@ -4,16 +4,15 @@ import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw"; import type { TenantSummary } from "../../../lib/adminApi"; import { parseTenantCSV } from "./tenantCsvImport"; -const seedTenantSlugs = new Set( - parseTenantCSV(seedTenantCSVRaw) - .map((row) => row.slug.trim().toLowerCase()) - .filter(Boolean), +const seedTenants = parseTenantCSV(seedTenantCSVRaw); +const seedTenantIds = new Set( + seedTenants.map((row) => row.tenantId.trim().toLowerCase()).filter(Boolean), ); -export function isSeedTenant(tenant: Pick): boolean { - return seedTenantSlugs.has(tenant.slug.trim().toLowerCase()); +export function isSeedTenant(tenant: Pick): boolean { + return seedTenantIds.has(tenant.id.trim().toLowerCase()); } -export function getSeedTenantSlugs(): string[] { - return Array.from(seedTenantSlugs); +export function getSeedTenantIds(): string[] { + return Array.from(seedTenantIds); } diff --git a/adminfront/src/features/users/GlobalCustomClaimsPage.test.tsx b/adminfront/src/features/users/GlobalCustomClaimsPage.test.tsx new file mode 100644 index 00000000..59b26657 --- /dev/null +++ b/adminfront/src/features/users/GlobalCustomClaimsPage.test.tsx @@ -0,0 +1,96 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createI18nMock } from "../../test/i18nMock"; +import GlobalCustomClaimsPage from "./GlobalCustomClaimsPage"; + +const fetchGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn()); +const updateGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../lib/i18n", () => createI18nMock()); + +vi.mock("../../lib/adminApi", () => ({ + fetchGlobalCustomClaimDefinitions: fetchGlobalCustomClaimDefinitionsMock, + updateGlobalCustomClaimDefinitions: updateGlobalCustomClaimDefinitionsMock, +})); + +vi.mock("../../components/ui/use-toast", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +function renderGlobalCustomClaimsPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + + + , + ); +} + +describe("GlobalCustomClaimsPage", () => { + beforeEach(() => { + fetchGlobalCustomClaimDefinitionsMock.mockReset(); + fetchGlobalCustomClaimDefinitionsMock.mockResolvedValue({ + items: [ + { + key: "locale", + label: "Locale", + valueType: "text", + readPermission: "admin_only", + writePermission: "admin_only", + description: "", + }, + ], + }); + updateGlobalCustomClaimDefinitionsMock.mockReset(); + updateGlobalCustomClaimDefinitionsMock.mockResolvedValue({ items: [] }); + }); + + it("forces user read permission on when user write permission is enabled", async () => { + renderGlobalCustomClaimsPage(); + + const readSelect = await screen.findByTestId( + "global-claim-definition-read-permission-locale", + ); + const writeSelect = await screen.findByTestId( + "global-claim-definition-write-permission-locale", + ); + + expect(readSelect).toHaveValue("admin_only"); + expect(writeSelect).toHaveValue("admin_only"); + + fireEvent.change(writeSelect, { target: { value: "user_and_admin" } }); + + await waitFor(() => { + expect(readSelect).toHaveValue("user_and_admin"); + expect(writeSelect).toHaveValue("user_and_admin"); + }); + + fireEvent.click(screen.getByRole("button", { name: /저장|Save/ })); + + await waitFor(() => { + expect(updateGlobalCustomClaimDefinitionsMock).toHaveBeenCalled(); + }); + expect(updateGlobalCustomClaimDefinitionsMock.mock.calls[0][0]).toEqual({ + items: [ + expect.objectContaining({ + key: "locale", + readPermission: "user_and_admin", + writePermission: "user_and_admin", + }), + ], + }); + }); +}); diff --git a/adminfront/src/features/users/GlobalCustomClaimsPage.tsx b/adminfront/src/features/users/GlobalCustomClaimsPage.tsx index 227e9747..b191fa57 100644 --- a/adminfront/src/features/users/GlobalCustomClaimsPage.tsx +++ b/adminfront/src/features/users/GlobalCustomClaimsPage.tsx @@ -52,6 +52,7 @@ function toDrafts(items: GlobalCustomClaimDefinition[]): ClaimDraft[] { function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] { return drafts + .map((draft) => normalizeClaimDraftPermissions(draft)) .map((draft) => ({ key: draft.key.trim(), label: draft.label.trim(), @@ -63,6 +64,16 @@ function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] { .filter((draft) => draft.key.length > 0); } +function normalizeClaimDraftPermissions(draft: ClaimDraft): ClaimDraft { + if (draft.writePermission !== "user_and_admin") { + return draft; + } + return { + ...draft, + readPermission: "user_and_admin", + }; +} + function permissionLabel(permission: GlobalCustomClaimPermission) { return permission === "user_and_admin" ? t( @@ -116,7 +127,9 @@ export default function GlobalCustomClaimsPage() { const updateClaim = (id: string, patch: Partial) => { setDrafts((current) => current.map((draft) => - draft.id === id ? { ...draft, ...patch } : draft, + draft.id === id + ? normalizeClaimDraftPermissions({ ...draft, ...patch }) + : draft, ), ); }; @@ -140,7 +153,7 @@ export default function GlobalCustomClaimsPage() { )} description={t( "msg.admin.users.global_custom_claims.description", - "모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다.", + "모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다.", )} actions={ <> @@ -185,7 +198,7 @@ export default function GlobalCustomClaimsPage() { {t( "msg.admin.users.global_custom_claims.registry", - "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다.", + "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다.", )} diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index f57773af..1cf8a3e7 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -2051,7 +2051,7 @@ function UserDetailPage() { {t( "msg.admin.users.detail.custom_claims.description", - "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.", + "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.", )} diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx index 16bd1cc7..94a5c589 100644 --- a/adminfront/src/features/users/UserListPage.render.test.tsx +++ b/adminfront/src/features/users/UserListPage.render.test.tsx @@ -22,6 +22,7 @@ const users = Array.from({ length: 200 }, (_, index) => ({ })); const fetchUsersMock = vi.hoisted(() => vi.fn()); +const fetchAllTenantsMock = vi.hoisted(() => vi.fn()); const searchRenderBudgetMs = process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300; @@ -34,10 +35,7 @@ vi.mock("../../lib/adminApi", () => ({ name: "Admin", email: "admin@example.com", })), - fetchAllTenants: vi.fn(async () => ({ - items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }], - total: 1, - })), + fetchAllTenants: fetchAllTenantsMock, fetchTenant: vi.fn(async () => ({ id: "tenant-1", name: "한맥", @@ -108,6 +106,11 @@ describe("UserListPage search rendering", () => { beforeEach(() => { selectRenderCounter.count = 0; fetchUsersMock.mockReset(); + fetchAllTenantsMock.mockReset(); + fetchAllTenantsMock.mockResolvedValue({ + items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }], + total: 1, + }); fetchUsersMock.mockImplementation( async (_limit: number, _offset: number, search?: string) => { const normalizedSearch = search?.trim().toLowerCase(); @@ -157,7 +160,7 @@ describe("UserListPage search rendering", () => { expect(content).toHaveClass("flex", "h-full", "items-center"); }); - it("renders additional tenant appointments in the tenant column", async () => { + it("does not render private additional tenant appointments in the tenant column", async () => { fetchUsersMock.mockResolvedValueOnce({ items: [ { @@ -183,7 +186,63 @@ describe("UserListPage search rendering", () => { expect( await screen.findByText("Additional Tenant User"), ).toBeInTheDocument(); - expect(screen.getByText("비공개 팀")).toBeInTheDocument(); + expect(screen.getAllByText("한맥").length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument(); + }); + + it("excludes private tenants when choosing the representative tenant for the user list", async () => { + fetchAllTenantsMock.mockResolvedValueOnce({ + items: [ + { + id: "tenant-private", + name: "비공개 팀", + slug: "private-team", + config: { visibility: "private" }, + }, + { + id: "tenant-public", + name: "공개 팀", + slug: "public-team", + config: { visibility: "public" }, + }, + ], + total: 2, + }); + fetchUsersMock.mockResolvedValueOnce({ + items: [ + { + ...users[0], + name: "Private Primary User", + tenantSlug: "private-team", + tenant: { + id: "tenant-private", + name: "비공개 팀", + slug: "private-team", + config: { visibility: "private" }, + }, + joinedTenants: [ + { + id: "tenant-public", + name: "공개 팀", + slug: "public-team", + config: { visibility: "public" }, + }, + ], + metadata: { + primaryTenantId: "tenant-private", + primaryTenantSlug: "private-team", + primaryTenantName: "비공개 팀", + }, + }, + ], + total: 1, + }); + + renderUserListPage(); + + expect(await screen.findByText("Private Primary User")).toBeInTheDocument(); + expect(screen.getByText("공개 팀")).toBeInTheDocument(); + expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument(); }); it("centers the initial loading message across the user table", async () => { diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 9af0473c..3708ea9f 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -151,50 +151,111 @@ function assignableSystemRoleValue(role?: string | null) { return isSuperAdminRole(role) ? "super_admin" : "user"; } -function collectAdditionalTenantLabels(user: UserSummary) { - const primaryKeys = new Set( - [user.tenant?.id, user.tenant?.slug, user.tenantSlug] - .filter((value): value is string => Boolean(value)) - .map((value) => value.toLowerCase()), +type RepresentativeTenantCandidate = { + id?: string; + slug?: string; + name?: string; + config?: Record; +}; + +function stringValue(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function tenantVisibility(tenant?: RepresentativeTenantCandidate) { + const visibility = tenant?.config?.visibility; + return typeof visibility === "string" ? visibility.trim() : ""; +} + +function findTenantCandidate( + candidate: RepresentativeTenantCandidate, + tenants: TenantSummary[], +) { + const id = candidate.id?.toLowerCase() ?? ""; + const slug = candidate.slug?.toLowerCase() ?? ""; + if (!id && !slug) return undefined; + return tenants.find( + (tenant) => + (id && tenant.id.toLowerCase() === id) || + (slug && tenant.slug.toLowerCase() === slug), ); - const labels: string[] = []; - const seen = new Set(); - const addLabel = ( - tenantId?: unknown, - tenantSlug?: unknown, - tenantName?: unknown, - ) => { - const id = typeof tenantId === "string" ? tenantId.trim() : ""; - const slug = typeof tenantSlug === "string" ? tenantSlug.trim() : ""; - const name = typeof tenantName === "string" ? tenantName.trim() : ""; - const key = (id || slug || name).toLowerCase(); - if (!key || primaryKeys.has(key) || seen.has(key)) { - return; - } - seen.add(key); - labels.push(name || slug || id); - }; +} + +function isPrivateTenantCandidate( + candidate: RepresentativeTenantCandidate, + tenants: TenantSummary[], +) { + const tenant = findTenantCandidate(candidate, tenants) ?? candidate; + return tenantVisibility(tenant) === "private"; +} + +function candidateLabel(candidate: RepresentativeTenantCandidate) { + return candidate.name || candidate.slug || candidate.id || ""; +} + +function metadataTenantCandidate( + metadata: Record | undefined, +): RepresentativeTenantCandidate | null { + const id = stringValue(metadata?.primaryTenantId); + const slug = stringValue(metadata?.primaryTenantSlug); + const name = stringValue(metadata?.primaryTenantName); + if (!id && !slug && !name) return null; + return { id, slug, name }; +} + +function appointmentTenantCandidate( + appointment: unknown, +): RepresentativeTenantCandidate | null { + if (!appointment || typeof appointment !== "object") return null; + const value = appointment as Record; + const id = stringValue(value.tenantId); + const slug = stringValue(value.tenantSlug ?? value.slug); + const name = stringValue(value.tenantName ?? value.name); + if (!id && !slug && !name) return null; + return { id, slug, name }; +} + +function resolveRepresentativeTenantLabel( + user: UserSummary, + tenants: TenantSummary[], +) { + const candidates: RepresentativeTenantCandidate[] = []; + const knownTenants = [ + ...(user.tenant ? [user.tenant] : []), + ...(user.joinedTenants ?? []), + ...tenants, + ]; + const primaryFromMetadata = metadataTenantCandidate(user.metadata); + if (primaryFromMetadata) candidates.push(primaryFromMetadata); + if (user.tenant) candidates.push(user.tenant); for (const tenant of user.joinedTenants ?? []) { - addLabel(tenant.id, tenant.slug, tenant.name); + candidates.push(tenant); } const appointments = user.metadata?.additionalAppointments; if (Array.isArray(appointments)) { for (const appointment of appointments) { - if (!appointment || typeof appointment !== "object") { + if ( + appointment && + typeof appointment === "object" && + (appointment as Record).isPrimary !== true + ) { continue; } - const value = appointment as Record; - addLabel( - value.tenantId, - value.tenantSlug ?? value.slug, - value.tenantName ?? value.name, - ); + const candidate = appointmentTenantCandidate(appointment); + if (candidate) candidates.push(candidate); } } + if (user.tenantSlug) candidates.push({ slug: user.tenantSlug }); - return labels; + const representative = candidates.find( + (candidate) => + candidateLabel(candidate) && + !isPrivateTenantCandidate(candidate, knownTenants), + ); + + return candidateLabel(representative ?? {}); } function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect { @@ -467,10 +528,10 @@ function UserListPage() { name_email: (user) => `${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`, tenant_dept: (user) => - `${user.tenant?.name ?? user.tenantSlug ?? ""} ${collectAdditionalTenantLabels(user).join(" ")} ${user.department ?? ""}`, + `${resolveRepresentativeTenantLabel(user, tenants)} ${user.department ?? ""}`, }, ), - [userSchema], + [tenants, userSchema], ); const items = React.useMemo(() => { if (!sortConfig) { @@ -1019,8 +1080,9 @@ function UserListPage() { virtualRows.map((virtualRow) => { const user = items[virtualRow.index]; if (!user) return null; - const additionalTenantLabels = - collectAdditionalTenantLabels(user); + const representativeTenantLabel = + resolveRepresentativeTenantLabel(user, tenants) || + t("ui.common.unassigned", "미배정"); return (
- {user.tenant?.name || - user.tenantSlug || - t("ui.common.unassigned", "미배정")} + {representativeTenantLabel} {user.department && ( {user.department} )} - {additionalTenantLabels.length > 0 && ( -
- {additionalTenantLabels.map((label) => ( - - {label} - - ))} -
- )}
{/* Dynamic Metadata Cells */} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index b241e0e3..4dad1404 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -348,9 +348,13 @@ update_error = "Failed to User Edit." update_success = "Update Success" [msg.admin.users.detail.custom_claims] -description = "Manage this user's values for globally defined custom claims. Add claim definitions and change types only from the global settings screen." +description = "Manage this user's values for globally defined custom claims. Read/Write indicates whether the user may view or update their own claim value. Add claim definitions and change types only from the global settings screen." empty = "No global custom claims have been defined." +[msg.admin.users.global_custom_claims] +description = "Manage user claim definitions shared across all RPs and the default user read/write permissions. Enabling write also enables read." +registry = "Only defined claim keys are available in per-user global claim values. Read/Write is a user self-service permission, not an administrator permission." + [msg.admin.users.detail.form] field_required = "Required." invalid_format = "Invalid format." diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index e0844fce..eaa3c692 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -353,9 +353,13 @@ update_success = "사용자 정보가 수정되었습니다." self_delete_blocked = "본인 계정은 삭제할 수 없습니다." [msg.admin.users.detail.custom_claims] -description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다." +description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다." empty = "전역으로 정의된 custom claim이 없습니다." +[msg.admin.users.global_custom_claims] +description = "모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다." +registry = "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다." + [msg.admin.users.detail.form] field_required = "필수입니다." invalid_format = "형식이 올바르지 않습니다." diff --git a/adminfront/tests/tenant_seed_protection.spec.ts b/adminfront/tests/tenant_seed_protection.spec.ts index 94b04f2f..d82d4360 100644 --- a/adminfront/tests/tenant_seed_protection.spec.ts +++ b/adminfront/tests/tenant_seed_protection.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; const tenants = [ { - id: "seed-hanmac", + id: "038326b6-954a-48a7-a85f-efd83f62b82a", name: "한맥가족", slug: "hanmac-family", type: "COMPANY_GROUP", @@ -13,6 +13,19 @@ const tenants = [ createdAt: "", updatedAt: "", }, + { + id: "5a03efd2-e62f-4243-800d-58334bf48b2f", + name: "한라산업개발", + slug: "hallasanup", + type: "COMPANY", + description: "네이버웍스 한라 HALLA_DOMAIN_ID", + status: "active", + domains: ["hallasanup.com"], + memberCount: 0, + parentId: "038326b6-954a-48a7-a85f-efd83f62b82a", + createdAt: "", + updatedAt: "", + }, { id: "normal-tenant", name: "일반 테넌트", @@ -96,11 +109,21 @@ test.describe("Seed tenant protection", () => { }) => { await page.goto("/tenants"); - const seedRow = page.getByRole("row", { name: /한맥가족/ }); + const seedRow = page.getByRole("row").filter({ + has: page.getByRole("link", { name: "한맥가족", exact: true }), + }); await expect(seedRow.getByRole("checkbox")).toHaveCount(0); await expect(seedRow.getByText("초기 설정")).toBeVisible(); - const normalRow = page.getByRole("row", { name: /일반 테넌트/ }); + const hallaRow = page.getByRole("row").filter({ + has: page.getByRole("link", { name: "한라산업개발", exact: true }), + }); + await expect(hallaRow.getByRole("checkbox")).toHaveCount(0); + await expect(hallaRow.getByText("초기 설정")).toBeVisible(); + + const normalRow = page.getByRole("row").filter({ + has: page.getByRole("link", { name: "일반 테넌트", exact: true }), + }); await expect(normalRow.getByRole("checkbox")).toBeEnabled(); }); diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index 1b1fdefb..f4097c72 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -293,6 +293,94 @@ test.describe("User Management", () => { }); }); + test("should hide private representative tenants in the user list row", async ({ + page, + }) => { + await page.route(/\/admin\/tenants(\?.*)?$/, async (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + json: { + items: [ + { + id: "tenant-private", + name: "비공개 팀", + slug: "private-team", + type: "USER_GROUP", + status: "active", + config: { visibility: "private" }, + }, + { + id: "tenant-public", + name: "공개 팀", + slug: "public-team", + type: "USER_GROUP", + status: "active", + config: { visibility: "public" }, + }, + ], + total: 2, + limit: 100, + offset: 0, + }, + }); + }); + await page.route(/\/admin\/users(\?.*)?$/, async (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + json: { + items: [ + { + id: "u-private", + name: "Private Primary User", + email: "private-primary@example.com", + phone: "010-0000-0000", + loginId: "private-primary", + role: "user", + status: "active", + tenantSlug: "private-team", + tenant: { + id: "tenant-private", + name: "비공개 팀", + slug: "private-team", + config: { visibility: "private" }, + }, + joinedTenants: [ + { + id: "tenant-public", + name: "공개 팀", + slug: "public-team", + config: { visibility: "public" }, + }, + ], + metadata: { + primaryTenantId: "tenant-private", + primaryTenantSlug: "private-team", + primaryTenantName: "비공개 팀", + }, + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-04-01T00:00:00Z", + }, + ], + total: 1, + limit: 50, + offset: 0, + }, + }); + }); + + await page.goto("/users"); + + const row = page.getByRole("row").filter({ + hasText: "Private Primary User", + }); + await expect(row).toContainText("공개 팀"); + await expect(row).not.toContainText("비공개 팀"); + }); + test("should successfully edit a user's Login ID", async ({ page }) => { await page.goto("/users/u-1"); diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go index 403f07e7..4e234ed1 100644 --- a/backend/internal/bootstrap/tenant_seed.go +++ b/backend/internal/bootstrap/tenant_seed.go @@ -14,7 +14,9 @@ import ( "log/slog" "os" "path/filepath" + "strconv" "strings" + "time" "gorm.io/gorm" ) @@ -51,6 +53,10 @@ func SeedTenants(db *gorm.DB) error { return errors.New("seed tenant csv has no tenant rows") } + if err := syncExistingSeedTenantConfigs(db, configs); err != nil { + return err + } + existingSlugs, existingIDs, err := loadExistingTenantIdentitySet(db) if err != nil { return err @@ -71,6 +77,69 @@ func SeedTenants(db *gorm.DB) error { return seedTenantConfigs(db, missingConfigs) } +func syncExistingSeedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error { + for _, config := range configs { + id := strings.TrimSpace(config.TenantID) + if id == "" { + continue + } + + targetSlug := strings.TrimSpace(strings.ToLower(config.Slug)) + if targetSlug == "" { + continue + } + + if err := db.Transaction(func(tx *gorm.DB) error { + var tenant domain.Tenant + if err := tx.First(&tenant, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return fmt.Errorf("load existing seed tenant %q: %w", id, err) + } + + if strings.TrimSpace(strings.ToLower(tenant.Slug)) == targetSlug { + return nil + } + + var conflict domain.Tenant + err := tx.Select("id"). + Where("LOWER(TRIM(slug)) = ? AND id <> ?", targetSlug, tenant.ID). + First(&conflict).Error + if err == nil { + return fmt.Errorf("seed tenant slug %q for id %q conflicts with tenant id %q", targetSlug, id, conflict.ID) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("check seed tenant slug conflict %q: %w", targetSlug, err) + } + + suffix := "-deleted-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + if err := tx.Unscoped(). + Model(&domain.Tenant{}). + Where("slug = ? AND id <> ? AND deleted_at IS NOT NULL", targetSlug, tenant.ID). + Update("slug", gorm.Expr("slug || ?", suffix)).Error; err != nil { + return fmt.Errorf("rename deleted tenant slug %q before seed repair: %w", targetSlug, err) + } + + slog.Info( + "[Bootstrap] Repairing existing seed tenant slug", + "id", tenant.ID, + "oldSlug", tenant.Slug, + "newSlug", targetSlug, + ) + if err := tx.Model(&domain.Tenant{}). + Where("id = ?", tenant.ID). + Update("slug", targetSlug).Error; err != nil { + return fmt.Errorf("repair seed tenant slug %q for id %q: %w", targetSlug, id, err) + } + return nil + }); err != nil { + return err + } + } + return nil +} + func loadExistingTenantIdentitySet(db *gorm.DB) (map[string]bool, map[string]bool, error) { var tenants []domain.Tenant if err := db.Select("id", "slug").Find(&tenants).Error; err != nil { diff --git a/backend/internal/bootstrap/tenant_seed_test.go b/backend/internal/bootstrap/tenant_seed_test.go index 829153ac..e413498e 100644 --- a/backend/internal/bootstrap/tenant_seed_test.go +++ b/backend/internal/bootstrap/tenant_seed_test.go @@ -273,7 +273,7 @@ func TestFilterMissingSeedTenantConfigsSkipsExistingSlugs(t *testing.T) { } } -func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testing.T) { +func TestSeedTenantsCreatesMissingSeedRowsAndRepairsExistingSeedSlug(t *testing.T) { if !testsupport.DockerAvailable() { t.Skip("Docker provider is unavailable in this environment") } @@ -326,18 +326,30 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, } + existingSeedTenantWithTypoSlug := domain.Tenant{ + ID: "5a03efd2-e62f-4243-800d-58334bf48b2f", + Name: "한라산업개발", + Slug: "hanlla", + Type: domain.TenantTypeCompany, + Description: "seed tenant with a typo slug must be repaired by UUID", + Status: domain.TenantStatusActive, + ParentID: &existingRoot.ID, + } if err := db.Create(&existingRoot).Error; err != nil { t.Fatalf("failed to create existing root tenant: %v", err) } if err := db.Create(&nonSeedTenant).Error; err != nil { t.Fatalf("failed to create non-seed tenant: %v", err) } + if err := db.Create(&existingSeedTenantWithTypoSlug).Error; err != nil { + t.Fatalf("failed to create existing seed tenant with typo slug: %v", err) + } dir := t.TempDir() path := filepath.Join(dir, "seed-tenant.csv") csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" + "10000000-0000-0000-0000-000000000001,Seed Root Name,COMPANY_GROUP,,existing-root,seed must be skipped,\n" + - "00000000-0000-0000-0000-000000000002,Conflicting ID,COMPANY,existing-root,conflicting-id,seed id must be skipped,\n" + + "5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,existing-root,halla,seed typo slug must be repaired,hallasanup.com\n" + "10000000-0000-0000-0000-000000000002,Missing Child,COMPANY,existing-root,missing-child,created from seed,child.example.com\n" if err := os.WriteFile(path, []byte(csv), 0o600); err != nil { t.Fatalf("failed to write seed csv: %v", err) @@ -359,6 +371,22 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin t.Fatalf("existing root name = %q, want untouched %q", root.Name, existingRoot.Name) } + var manual domain.Tenant + if err := db.First(&manual, "id = ?", nonSeedTenant.ID).Error; err != nil { + t.Fatalf("failed to load non-seed tenant after seed: %v", err) + } + if manual.Slug != nonSeedTenant.Slug { + t.Fatalf("non-seed tenant slug = %q, want untouched %q", manual.Slug, nonSeedTenant.Slug) + } + + var repairedSeed domain.Tenant + if err := db.First(&repairedSeed, "id = ?", existingSeedTenantWithTypoSlug.ID).Error; err != nil { + t.Fatalf("failed to load existing seed tenant after seed: %v", err) + } + if repairedSeed.Slug != "halla" { + t.Fatalf("existing seed tenant slug = %q, want halla", repairedSeed.Slug) + } + var child domain.Tenant if err := db.Preload("Domains").First(&child, "slug = ?", "missing-child").Error; err != nil { t.Fatalf("missing seed child was not created: %v", err) @@ -378,11 +406,4 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin t.Fatalf("existing-root row count = %d, want 1", rootCount) } - var conflictingIDCount int64 - if err := db.Model(&domain.Tenant{}).Where("slug = ?", "conflicting-id").Count(&conflictingIDCount).Error; err != nil { - t.Fatalf("failed to count conflicting-id rows: %v", err) - } - if conflictingIDCount != 0 { - t.Fatalf("conflicting-id row count = %d, want 0", conflictingIDCount) - } } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index f3771e0d..4ca11585 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -17,6 +17,7 @@ import ( "io" "log/slog" "maps" + "math" "math/rand" "net" "net/http" @@ -1646,6 +1647,154 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string return baseClaims } +func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any { + if claims == nil { + claims = map[string]any{} + } + if h == nil || h.RPUserMetadataRepo == nil { + return claims + } + + clientID := strings.TrimSpace(client.ClientID) + subject = strings.TrimSpace(subject) + if clientID == "" || subject == "" { + return claims + } + + rpClaimDefinitions := extractRPClaimDefinitions(client.Metadata) + if len(rpClaimDefinitions) == 0 { + return claims + } + + row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject) + if err != nil || row == nil || len(row.Metadata) == 0 { + return claims + } + + rpClaims, _ := claims["rp_claims"].(map[string]any) + if rpClaims == nil { + rpClaims = map[string]any{} + } + + for _, claim := range rpClaimDefinitions { + raw, ok := row.Metadata[claim.Key] + if !ok || raw == nil { + continue + } + value, ok := coerceRPUserMetadataClaimValue(raw, claim.ValueType) + if !ok { + slog.Warn("failed to coerce rp user metadata claim", "client_id", clientID, "subject", subject, "key", claim.Key, "value_type", claim.ValueType) + continue + } + rpClaims[claim.Key] = value + } + + if len(rpClaims) > 0 { + claims["rp_claims"] = rpClaims + } + return claims +} + +func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim { + if metadata == nil { + return nil + } + rawClaims, ok := metadata[domain.MetadataIDTokenClaims] + if !ok || rawClaims == nil { + return nil + } + normalizedClaims, err := normalizeIDTokenClaims(rawClaims) + if err != nil { + slog.Warn("failed to normalize rp claim definitions", "error", err) + return nil + } + definitions := make([]normalizedIDTokenClaim, 0, len(normalizedClaims)) + seen := make(map[string]struct{}, len(normalizedClaims)) + for _, claim := range normalizedClaims { + if claim.Namespace != "rp_claims" { + continue + } + if _, exists := seen[claim.Key]; exists { + continue + } + seen[claim.Key] = struct{}{} + definitions = append(definitions, claim) + } + return definitions +} + +func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) { + switch value := raw.(type) { + case string: + if strings.TrimSpace(value) == "" { + return nil, false + } + parsed, err := parseConfiguredClaimValue(value, valueType) + return parsed, err == nil + case []any: + if valueType == "array" { + return value, true + } + case []string: + if valueType == "array" { + items := make([]any, 0, len(value)) + for _, item := range value { + items = append(items, item) + } + return items, true + } + case map[string]any: + if valueType == "object" { + return value, true + } + case bool: + if valueType == "boolean" { + return value, true + } + case float64: + if valueType == "float" { + return value, true + } + if valueType == "number" && value == math.Trunc(value) { + return value, true + } + case float32: + floatValue := float64(value) + if valueType == "float" { + return floatValue, true + } + if valueType == "number" && floatValue == math.Trunc(floatValue) { + return floatValue, true + } + case int: + if valueType == "number" { + return float64(value), true + } + if valueType == "float" { + return float64(value), true + } + case int64: + if valueType == "number" { + return float64(value), true + } + if valueType == "float" { + return float64(value), true + } + case json.Number: + if valueType == "number" { + parsed, err := value.Int64() + return float64(parsed), err == nil + } + if valueType == "float" { + parsed, err := value.Float64() + return parsed, err == nil + } + } + + parsed, err := parseConfiguredClaimValue(fmt.Sprint(raw), valueType) + return parsed, err == nil +} + func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any { if claims == nil { claims = map[string]any{} @@ -6046,6 +6195,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { currentSessionID, ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) + sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err == nil { @@ -6084,6 +6234,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { currentSessionID, ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) + sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시) @@ -6275,6 +6426,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { currentSessionID, ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) + sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용) diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index 64ed8d2f..2f9bd7df 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -827,3 +827,148 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) { assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"]) } } + +func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) { + var capturedClaims map[string]any + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + 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"}, + "subject": "user-rp-claims", + "client": map[string]any{ + "client_id": "client-rp-claims", + "metadata": map[string]any{ + "id_token_claims": []map[string]any{ + { + "namespace": "rp_claims", + "key": "approvalLevel", + "value": "A", + "valueType": "text", + }, + { + "namespace": "rp_claims", + "key": "activeMember", + "value": "true", + "valueType": "boolean", + }, + { + "namespace": "rp_claims", + "key": "score", + "value": "1", + "valueType": "number", + }, + { + "namespace": "rp_claims", + "key": "featureList", + "value": `["default"]`, + "valueType": "array", + }, + { + "namespace": "rp_claims", + "key": "preferences", + "value": `{"theme":"light","density":"comfortable"}`, + "valueType": "object", + }, + { + "namespace": "rp_claims", + "key": "contractDate", + "value": "2026-06-09", + "valueType": "date", + }, + { + "namespace": "rp_claims", + "key": "approvedAt", + "value": "2026-06-09T09:30", + "valueType": "datetime", + }, + }, + }, + }, + }), nil + } + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" { + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]any + json.Unmarshal(body, &acceptReq) + if session, ok := acceptReq["session"].(map[string]any); ok { + capturedClaims = session["id_token"].(map[string]any) + } + + return httpJSONAny(r, http.StatusOK, map[string]any{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + } + h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-rp-claims").Return(&service.KratosIdentity{ + ID: "user-rp-claims", + Traits: map[string]any{ + "email": "rp-user@example.com", + "name": "RP User", + }, + }, nil) + repo := new(devMockRPUserMetadataRepo) + repo.On("Get", mock.Anything, "client-rp-claims", "user-rp-claims").Return(&domain.RPUserMetadata{ + ClientID: "client-rp-claims", + UserID: "user-rp-claims", + Metadata: domain.JSONMap{ + "approvalLevel": "B", + "activeMember": false, + "score": float64(42), + "featureList": []any{"sso", "claims"}, + "preferences": map[string]any{ + "theme": "dark", + "density": "compact", + }, + "contractDate": "2026-06-10", + "approvedAt": "2026-06-09T10:30", + "internalMemo": "must-not-leak", + "approvalLevel_permissions": map[string]any{ + "readPermission": "admin_only", + "writePermission": "user_and_admin", + }, + }, + }, nil).Once() + h.RPUserMetadataRepo = repo + + app := fiber.New() + app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) + + reqBody, _ := json.Marshal(map[string]any{ + "consent_challenge": "challenge-rp-user-claims", + "grant_scope": []string{"openid", "profile"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.NotNil(t, capturedClaims) + rpClaims, ok := capturedClaims["rp_claims"].(map[string]any) + if assert.True(t, ok) { + assert.Equal(t, "B", rpClaims["approvalLevel"]) + assert.Equal(t, false, rpClaims["activeMember"]) + assert.Equal(t, float64(42), rpClaims["score"]) + assert.Equal(t, []any{"sso", "claims"}, rpClaims["featureList"]) + assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaims["preferences"]) + assert.Equal(t, "2026-06-10", rpClaims["contractDate"]) + assert.Equal(t, "2026-06-09T10:30", rpClaims["approvedAt"]) + assert.NotContains(t, rpClaims, "internalMemo") + assert.NotContains(t, rpClaims, "approvalLevel_permissions") + } + assert.NotContains(t, capturedClaims, "rp_profiles") + repo.AssertExpectations(t) +} diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 50fd5498..2afd1b12 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -3588,7 +3588,7 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor valueType = "text" } switch valueType { - case "text", "number", "boolean", "array", "object", "date", "datetime": + case "text", "number", "float", "boolean", "array", "object", "date", "datetime": default: return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType) } @@ -3641,9 +3641,24 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) { if trimmed == "" { return nil, errors.New("number value is required") } + if !isIntegerClaimLiteral(trimmed) { + return nil, errors.New("number value must be an integer") + } parsed, err := strconv.ParseFloat(trimmed, 64) if err != nil { - return nil, errors.New("number value must be a finite number") + return nil, errors.New("number value must be an integer") + } + return parsed, nil + case "float": + if trimmed == "" { + return nil, errors.New("float value is required") + } + if !isFloatClaimLiteral(trimmed) { + return nil, errors.New("float value must be a finite decimal number") + } + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return nil, errors.New("float value must be a finite decimal number") } return parsed, nil case "boolean": @@ -3708,6 +3723,54 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) { } } +func isIntegerClaimLiteral(value string) bool { + if value == "" { + return false + } + start := 0 + if value[0] == '-' { + if len(value) == 1 { + return false + } + start = 1 + } + for _, char := range value[start:] { + if char < '0' || char > '9' { + return false + } + } + return true +} + +func isFloatClaimLiteral(value string) bool { + if value == "" { + return false + } + start := 0 + if value[0] == '-' { + if len(value) == 1 { + return false + } + start = 1 + } + hasDigit := false + hasDot := false + for _, char := range value[start:] { + switch { + case char >= '0' && char <= '9': + hasDigit = true + case char == '.': + if hasDot { + return false + } + hasDot = true + default: + return false + } + } + return hasDigit +} + func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool { if req.Jwks != nil { return true diff --git a/backend/internal/handler/dev_handler_rp_metadata_test.go b/backend/internal/handler/dev_handler_rp_metadata_test.go index a1ba4bdc..5d2a39c6 100644 --- a/backend/internal/handler/dev_handler_rp_metadata_test.go +++ b/backend/internal/handler/dev_handler_rp_metadata_test.go @@ -60,6 +60,30 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) { "valueType": "number", "value": "1", }, + { + "namespace": "rp_claims", + "key": "featureList", + "valueType": "array", + "value": `["default"]`, + }, + { + "namespace": "rp_claims", + "key": "preferences", + "valueType": "object", + "value": `{"theme":"light","density":"comfortable"}`, + }, + { + "namespace": "rp_claims", + "key": "contractDate", + "valueType": "date", + "value": "2026-06-09", + }, + { + "namespace": "rp_claims", + "key": "approvedAt", + "valueType": "datetime", + "value": "2026-06-09T09:30", + }, }, }, }), nil @@ -74,8 +98,14 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) { row.Metadata["approvalLevel"] == "A" && row.Metadata["activeMember"] == false && row.Metadata["score"] == float64(42) && + assert.ObjectsAreEqual([]any{"sso", "claims"}, row.Metadata["featureList"]) && + assert.ObjectsAreEqual(map[string]any{"theme": "dark", "density": "compact"}, row.Metadata["preferences"]) && + row.Metadata["contractDate"] == "2026-06-10" && + row.Metadata["approvedAt"] == "2026-06-09T10:30" && row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" && - row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" + row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" && + row.Metadata["featureList_permissions"].(map[string]any)["readPermission"] == "admin_only" && + row.Metadata["featureList_permissions"].(map[string]any)["writePermission"] == "admin_only" })).Return(nil).Once() repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{ ClientID: "client-1", @@ -103,6 +133,13 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) { "approvalLevel": "A", "activeMember": false, "score": 42, + "featureList": []string{"sso", "claims"}, + "preferences": map[string]any{ + "theme": "dark", + "density": "compact", + }, + "contractDate": "2026-06-10", + "approvedAt": "2026-06-09T10:30", "approvalLevel_permissions": map[string]any{ "writePermission": "user_and_admin", }, diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 5da3df7f..5e99893f 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -2520,6 +2520,13 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) { "value": "2", "valueType": "number", }, + { + "id": "claim-3", + "namespace": "rp_claims", + "key": "ratio", + "value": "3.14", + "valueType": "float", + }, }, }, }) @@ -2530,7 +2537,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) { assert.Equal(t, http.StatusCreated, resp.StatusCode) claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]any) - if assert.True(t, ok) && assert.Len(t, claims, 2) { + if assert.True(t, ok) && assert.Len(t, claims, 3) { first, ok := claims[0].(map[string]any) if assert.True(t, ok) { assert.Equal(t, "rp_claims", first["namespace"]) @@ -2548,6 +2555,14 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) { assert.Equal(t, "2", second["value"]) assert.Equal(t, "number", second["valueType"]) } + + third, ok := claims[2].(map[string]any) + if assert.True(t, ok) { + assert.Equal(t, "rp_claims", third["namespace"]) + assert.Equal(t, "ratio", third["key"]) + assert.Equal(t, "3.14", third["value"]) + assert.Equal(t, "float", third["valueType"]) + } } } diff --git a/backend/internal/handler/rp_claims_e2e_test.go b/backend/internal/handler/rp_claims_e2e_test.go new file mode 100644 index 00000000..75d383e5 --- /dev/null +++ b/backend/internal/handler/rp_claims_e2e_test.go @@ -0,0 +1,328 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type rpClaimsE2ERepo struct { + mu sync.Mutex + rows map[string]*domain.RPUserMetadata + getKeys []string +} + +func newRPClaimsE2ERepo() *rpClaimsE2ERepo { + return &rpClaimsE2ERepo{rows: map[string]*domain.RPUserMetadata{}} +} + +func (r *rpClaimsE2ERepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) { + r.mu.Lock() + defer r.mu.Unlock() + + key := rpClaimsE2ERepoKey(clientID, userID) + r.getKeys = append(r.getKeys, key) + row, ok := r.rows[key] + if !ok { + return nil, fmt.Errorf("rp user metadata not found") + } + return cloneRPUserMetadata(row), nil +} + +func (r *rpClaimsE2ERepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.rows[rpClaimsE2ERepoKey(metadata.ClientID, metadata.UserID)] = cloneRPUserMetadata(metadata) + return nil +} + +func (r *rpClaimsE2ERepo) seenGet(clientID, userID string) bool { + r.mu.Lock() + defer r.mu.Unlock() + + key := rpClaimsE2ERepoKey(clientID, userID) + for _, seen := range r.getKeys { + if seen == key { + return true + } + } + return false +} + +func rpClaimsE2ERepoKey(clientID, userID string) string { + return strings.TrimSpace(clientID) + "\x00" + strings.TrimSpace(userID) +} + +func cloneRPUserMetadata(row *domain.RPUserMetadata) *domain.RPUserMetadata { + if row == nil { + return nil + } + cloned := &domain.RPUserMetadata{ + ClientID: row.ClientID, + UserID: row.UserID, + Metadata: domain.JSONMap{}, + } + for key, value := range row.Metadata { + cloned.Metadata[key] = value + } + return cloned +} + +func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) { + const userID = "user-rp-e2e" + const clientA = "client-rp-a" + const clientB = "client-rp-b" + + clients := map[string]map[string]any{ + clientA: rpClaimsE2EClient(clientA, []map[string]any{ + rpClaimsE2EClaim("approvalLevel", "text", "A", "user_and_admin", "user_and_admin"), + rpClaimsE2EClaim("activeMember", "boolean", "true", "user_and_admin", "user_and_admin"), + rpClaimsE2EClaim("score", "number", "1", "user_and_admin", "user_and_admin"), + rpClaimsE2EClaim("featureList", "array", `["default"]`, "user_and_admin", "user_and_admin"), + rpClaimsE2EClaim("preferences", "object", `{"theme":"light","density":"comfortable"}`, "user_and_admin", "user_and_admin"), + rpClaimsE2EClaim("contractDate", "date", "2026-06-09", "user_and_admin", "user_and_admin"), + rpClaimsE2EClaim("approvedAt", "datetime", "2026-06-09T09:30", "user_and_admin", "user_and_admin"), + rpClaimsE2EClaim("adminManagedNote", "text", "admin-default", "user_and_admin", "admin_only"), + }), + clientB: rpClaimsE2EClient(clientB, []map[string]any{ + rpClaimsE2EClaim("approvalLevel", "text", "B-default", "user_and_admin", "user_and_admin"), + rpClaimsE2EClaim("activeMember", "boolean", "false", "user_and_admin", "user_and_admin"), + }), + } + challenges := map[string]string{ + "challenge-client-a-default": clientA, + "challenge-client-a-admin-update": clientA, + "challenge-client-a-self-update": clientA, + "challenge-client-b-default": clientB, + "challenge-client-b-update": clientB, + } + capturedClaims := map[string]map[string]any{} + hydraClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if strings.HasPrefix(r.URL.Path, "/clients/") { + clientID := strings.TrimPrefix(r.URL.Path, "/clients/") + client, ok := clients[clientID] + if !ok { + return httpJSONAny(r, http.StatusNotFound, nil), nil + } + return httpJSONAny(r, http.StatusOK, client), nil + } + if r.URL.Path == "/oauth2/auth/requests/consent" { + challenge := r.URL.Query().Get("consent_challenge") + clientID, ok := challenges[challenge] + if !ok { + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + return httpJSONAny(r, http.StatusOK, map[string]any{ + "challenge": challenge, + "requested_scope": []string{"openid", "profile"}, + "subject": userID, + "client": clients[clientID], + }), nil + } + if r.URL.Path == "/oauth2/auth/requests/consent/accept" { + challenge := r.URL.Query().Get("consent_challenge") + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]any + _ = json.Unmarshal(body, &acceptReq) + session, _ := acceptReq["session"].(map[string]any) + idToken, _ := session["id_token"].(map[string]any) + capturedClaims[challenge] = idToken + return httpJSONAny(r, http.StatusOK, map[string]any{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + + repo := newRPClaimsE2ERepo() + kratos := new(MockKratosAdminService) + kratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ + ID: userID, + State: "active", + Traits: map[string]any{ + "email": "rp-e2e@example.com", + "name": "RP E2E User", + }, + }, nil) + + authHandler := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: hydraClient, + }, + KratosAdmin: kratos, + RPUserMetadataRepo: repo, + } + devHandler := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: hydraClient, + }, + RPUserMetadataRepo: repo, + } + + app := fiber.New() + app.Put("/api/v1/dev/clients/:id/users/me/metadata", func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: userID, Role: domain.RoleUser}) + return devHandler.SelfUpdateRPUserMetadata(c) + }) + app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin}) + return devHandler.UpsertRPUserMetadata(c) + }) + app.Post("/api/v1/auth/consent/accept", authHandler.AcceptConsentRequest) + + initialA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-default") + assert.Equal(t, "A", initialA["approvalLevel"]) + assert.Equal(t, true, initialA["activeMember"]) + assert.Equal(t, float64(1), initialA["score"]) + assert.Equal(t, []any{"default"}, initialA["featureList"]) + assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, initialA["preferences"]) + assert.Equal(t, "2026-06-09", initialA["contractDate"]) + assert.Equal(t, "2026-06-09T09:30", initialA["approvedAt"]) + + upsertRPClaimsE2EMetadata(t, app, clientA, userID, map[string]any{ + "approvalLevel": "B", + "activeMember": false, + "score": 42, + "featureList": []string{"sso", "claims"}, + "preferences": map[string]any{"theme": "dark", "density": "compact"}, + "contractDate": "2026-06-10", + "approvedAt": "2026-06-09T10:30", + "adminManagedNote": "admin-updated", + "approvalLevel_permissions": map[string]any{ + "writePermission": "user_and_admin", + }, + }) + + updatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-admin-update") + assert.Equal(t, "B", updatedA["approvalLevel"]) + assert.Equal(t, false, updatedA["activeMember"]) + assert.Equal(t, float64(42), updatedA["score"]) + assert.Equal(t, []any{"sso", "claims"}, updatedA["featureList"]) + assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, updatedA["preferences"]) + assert.Equal(t, "2026-06-10", updatedA["contractDate"]) + assert.Equal(t, "2026-06-09T10:30", updatedA["approvedAt"]) + assert.Equal(t, "admin-updated", updatedA["adminManagedNote"]) + assert.NotContains(t, updatedA, "approvalLevel_permissions") + assert.NotContains(t, updatedA, "adminManagedNote_permissions") + + rejectedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{ + "metadata": map[string]any{ + "adminManagedNote": "user-should-not-overwrite", + }, + }) + assert.Equal(t, http.StatusForbidden, rejectedSelfUpdate.StatusCode) + + allowedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{ + "metadata": map[string]any{ + "approvalLevel": "C", + }, + }) + assert.Equal(t, http.StatusOK, allowedSelfUpdate.StatusCode) + + selfUpdatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-self-update") + assert.Equal(t, "C", selfUpdatedA["approvalLevel"]) + assert.Equal(t, "admin-updated", selfUpdatedA["adminManagedNote"]) + + defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default") + assert.Equal(t, "B-default", defaultB["approvalLevel"]) + assert.Equal(t, false, defaultB["activeMember"]) + assert.NotContains(t, defaultB, "score") + assert.NotContains(t, defaultB, "featureList") + assert.NotContains(t, defaultB, "adminManagedNote") + + upsertRPClaimsE2EMetadata(t, app, clientB, userID, map[string]any{ + "approvalLevel": "B-rp-only", + "activeMember": true, + }) + updatedB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-update") + assert.Equal(t, "B-rp-only", updatedB["approvalLevel"]) + assert.Equal(t, true, updatedB["activeMember"]) + assert.NotEqual(t, selfUpdatedA["approvalLevel"], updatedB["approvalLevel"]) + assert.NotContains(t, updatedB, "score") + assert.NotContains(t, updatedB, "featureList") + + require.True(t, repo.seenGet(clientA, userID)) + require.True(t, repo.seenGet(clientB, userID)) + require.Contains(t, capturedClaims, "challenge-client-a-admin-update") + require.Contains(t, capturedClaims, "challenge-client-b-update") + kratos.AssertExpectations(t) +} + +func rpClaimsE2EClient(clientID string, claims []map[string]any) map[string]any { + return map[string]any{ + "client_id": clientID, + "client_name": clientID, + "metadata": map[string]any{ + "tenant_id": "tenant-rp-e2e", + "id_token_claims": claims, + }, + } +} + +func rpClaimsE2EClaim(key, valueType, value, readPermission, writePermission string) map[string]any { + return map[string]any{ + "namespace": "rp_claims", + "key": key, + "valueType": valueType, + "value": value, + "readPermission": readPermission, + "writePermission": writePermission, + } +} + +func acceptRPClaimsE2EConsent(t *testing.T, app *fiber.App, capturedClaims map[string]map[string]any, challenge string) map[string]any { + t.Helper() + + body, _ := json.Marshal(map[string]any{ + "consent_challenge": challenge, + "grant_scope": []string{"openid", "profile"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, -1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + idToken, ok := capturedClaims[challenge] + require.True(t, ok) + rpClaims, ok := idToken["rp_claims"].(map[string]any) + require.True(t, ok) + return rpClaims +} + +func upsertRPClaimsE2EMetadata(t *testing.T, app *fiber.App, clientID, userID string, metadata map[string]any) { + t.Helper() + + resp := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientID+"/users/"+userID+"/metadata", map[string]any{ + "metadata": metadata, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func putRPClaimsE2EMetadata(t *testing.T, app *fiber.App, method, path string, body map[string]any) *http.Response { + t.Helper() + + payload, _ := json.Marshal(body) + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, -1) + require.NoError(t, err) + return resp +} diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 65b1d677..9b89f5ba 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -52,6 +52,7 @@ import { ClientDetailTabs } from "./ClientDetailTabs"; type RPClaimValueType = | "text" | "number" + | "float" | "boolean" | "array" | "object" @@ -167,7 +168,9 @@ function draftRowsToMetadata(rows: MetadataDraftRow[]) { function draftRowValueToMetadataValue(row: MetadataDraftRow) { const value = row.value.trim(); switch (row.valueType) { - case "number": { + case "number": + return /^-?\d+$/.test(value) ? Number.parseInt(value, 10) : value; + case "float": { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : value; } @@ -200,6 +203,7 @@ function isRPClaimValueType(value: string): value is RPClaimValueType { return ( value === "text" || value === "number" || + value === "float" || value === "boolean" || value === "array" || value === "object" || @@ -268,10 +272,21 @@ function readRPClaimSchemas( function rpClaimInputType(valueType: RPClaimValueType) { if (valueType === "date") return "date"; if (valueType === "datetime") return "datetime-local"; - if (valueType === "number") return "number"; return "text"; } +function rpClaimInputMode(valueType: RPClaimValueType) { + if (valueType === "number") return "numeric"; + if (valueType === "float") return "decimal"; + return undefined; +} + +function rpClaimInputPattern(valueType: RPClaimValueType) { + if (valueType === "number") return "-?[0-9]*"; + if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)"; + return undefined; +} + function ClientConsentsPage() { const params = useParams(); const clientId = params.id ?? ""; @@ -452,25 +467,6 @@ function ClientConsentsPage() { ); }; - const addMetadataDraftRow = () => { - setMetadataDraftRows((current) => [ - ...current, - { - id: `rp-metadata-${Date.now()}`, - key: "", - value: "", - valueType: "text", - readPermission: "admin_only", - writePermission: "admin_only", - schemaBacked: false, - }, - ]); - }; - - const removeMetadataDraftRow = (id: string) => { - setMetadataDraftRows((current) => current.filter((row) => row.id !== id)); - }; - if (error) { const axiosError = error as AxiosError<{ error?: string }>; if (axiosError.response?.status === 403) { @@ -958,16 +954,6 @@ function ClientConsentsPage() {

- {rpClaimSchemas.length === 0 && ( - - )} - )} + + {row.valueType} +
)) )} diff --git a/devfront/src/features/clients/ClientDetailTabs.test.tsx b/devfront/src/features/clients/ClientDetailTabs.test.tsx index 5b8effee..f8927187 100644 --- a/devfront/src/features/clients/ClientDetailTabs.test.tsx +++ b/devfront/src/features/clients/ClientDetailTabs.test.tsx @@ -7,7 +7,7 @@ vi.mock("../../lib/i18n", () => ({ t: (key: string, fallback?: string) => ({ "ui.dev.clients.details.tab.connection": "연동 설정", - "ui.dev.clients.details.tab.user_claims": "사용자 Claim", + "ui.dev.clients.details.tab.consents": "Consents & Claims", "ui.dev.clients.details.tab.settings": "설정", "ui.dev.clients.details.tab.relationships": "관계", })[key] ?? @@ -23,7 +23,7 @@ describe("ClientDetailTabs", () => { , ); - expect(html).toContain("사용자 Claim"); + expect(html).toContain("Consents & Claims"); expect(html).toContain('href="/clients/client-a/consents"'); }); }); diff --git a/devfront/src/features/clients/ClientDetailTabs.tsx b/devfront/src/features/clients/ClientDetailTabs.tsx index ebb174d3..c9a6b189 100644 --- a/devfront/src/features/clients/ClientDetailTabs.tsx +++ b/devfront/src/features/clients/ClientDetailTabs.tsx @@ -18,7 +18,6 @@ const tabOrder: Array<{ { key: "consents", href: (clientId) => `/clients/${clientId}/consents`, - labelKey: "ui.dev.clients.details.tab.user_claims", }, { key: "settings", href: (clientId) => `/clients/${clientId}/settings` }, { diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index a44d7cb9..87372149 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -126,6 +126,26 @@ async function setInputValue(input: HTMLInputElement, value: string) { await flush(); } +async function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + ); + descriptor?.set?.call(textarea, value); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); +} + +async function setSelectValue(select: HTMLSelectElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLSelectElement.prototype, + "value", + ); + descriptor?.set?.call(select, value); + select.dispatchEvent(new Event("change", { bubbles: true })); + await flush(); +} + async function renderPage() { const container = document.createElement("div"); document.body.appendChild(container); @@ -229,4 +249,225 @@ describe("ClientGeneralPage RP claims", () => { }, ]); }); + + it("forces user read permission on when user write permission is enabled for RP claims", async () => { + const { container } = await renderPage(); + + const switches = Array.from( + container.querySelectorAll('[role="switch"]'), + ); + const readSwitch = switches.find((button) => + /Read|읽기/.test(button.getAttribute("aria-label") ?? ""), + ); + const writeSwitch = switches.find((button) => + /Write|쓰기/.test(button.getAttribute("aria-label") ?? ""), + ); + + expect(readSwitch).toBeDefined(); + expect(writeSwitch).toBeDefined(); + expect(readSwitch?.getAttribute("aria-checked")).toBe("false"); + expect(writeSwitch?.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + writeSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(readSwitch?.getAttribute("aria-checked")).toBe("true"); + expect(writeSwitch?.getAttribute("aria-checked")).toBe("true"); + + 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: [ + expect.objectContaining({ + readPermission: "user_and_admin", + writePermission: "user_and_admin", + }), + ], + }), + }), + ); + }); + + it("keeps nullable and default value as separate RP claim settings", async () => { + const { container } = await renderPage(); + + expect(container.textContent).toContain("Nullable"); + expect(container.textContent).toContain("Default Value"); + expect(container.textContent).not.toContain("Nullable/default"); + expect(container.textContent).toContain( + "RP 전용 확장 claim을 구분해서 관리합니다", + ); + }); + + it("blocks saving a number RP claim default value that is not numeric", async () => { + const { container } = await renderPage(); + + const valueTypeSelect = container.querySelector( + 'select[aria-label="Claim 값 타입"]', + ); + expect(valueTypeSelect).not.toBeNull(); + await setSelectValue(valueTypeSelect as HTMLSelectElement, "number"); + + 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).not.toHaveBeenCalled(); + }); + + it("blocks saving a number RP claim default value that is not an integer", async () => { + const { container } = await renderPage(); + + const valueTypeSelect = container.querySelector( + 'select[aria-label="Claim 값 타입"]', + ); + expect(valueTypeSelect).not.toBeNull(); + await setSelectValue(valueTypeSelect as HTMLSelectElement, "number"); + + const defaultValueInput = container.querySelector( + 'input[placeholder="Enter the default value"]', + ); + expect(defaultValueInput).not.toBeNull(); + await setInputValue(defaultValueInput as HTMLInputElement, "3.14"); + + expect(container.textContent).toContain( + "Claim 기본값이 타입과 맞지 않습니다", + ); + + 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).not.toHaveBeenCalled(); + }); + + it("saves a float RP claim default value", async () => { + const { container } = await renderPage(); + + const valueTypeSelect = container.querySelector( + 'select[aria-label="Claim 값 타입"]', + ); + expect(valueTypeSelect).not.toBeNull(); + expect( + valueTypeSelect?.querySelector('option[value="float"]'), + ).not.toBeNull(); + await setSelectValue(valueTypeSelect as HTMLSelectElement, "float"); + + const defaultValueInput = container.querySelector( + 'input[placeholder="Enter the default value"]', + ); + expect(defaultValueInput).not.toBeNull(); + expect(defaultValueInput?.getAttribute("inputmode")).toBe("decimal"); + await setInputValue(defaultValueInput as HTMLInputElement, "3.14"); + + 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: [ + expect.objectContaining({ + value: "3.14", + valueType: "float", + }), + ], + }), + }), + ); + }); + + it("renders constrained default value controls for boolean and date RP claims", async () => { + const { container } = await renderPage(); + + const valueTypeSelect = container.querySelector( + 'select[aria-label="Claim 값 타입"]', + ); + expect(valueTypeSelect).not.toBeNull(); + + await setSelectValue(valueTypeSelect as HTMLSelectElement, "boolean"); + const booleanDefaultSelect = Array.from( + container.querySelectorAll("select"), + ).find((select) => + Array.from(select.options).some((option) => option.value === "false"), + ); + expect(booleanDefaultSelect).toBeDefined(); + + await setSelectValue(valueTypeSelect as HTMLSelectElement, "date"); + expect(container.querySelector('input[type="date"]')).not.toBeNull(); + }); + + it("blocks saving an object RP claim default value that is not a JSON object", async () => { + const { container } = await renderPage(); + + const valueTypeSelect = container.querySelector( + 'select[aria-label="Claim 값 타입"]', + ); + expect(valueTypeSelect).not.toBeNull(); + await setSelectValue(valueTypeSelect as HTMLSelectElement, "object"); + + const defaultValueInput = container.querySelector( + 'textarea[placeholder="{\\"key\\": \\"value\\"}"]', + ); + expect(defaultValueInput).not.toBeNull(); + await setTextareaValue( + defaultValueInput as HTMLTextAreaElement, + "not-json", + ); + + 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).not.toHaveBeenCalled(); + }); }); diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 994951e0..bd76d937 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -71,6 +71,7 @@ type ClaimNamespace = "rp_claims"; type ClaimValueType = | "text" | "number" + | "float" | "boolean" | "array" | "object" @@ -149,6 +150,7 @@ function isClaimValueType(value: string): value is ClaimValueType { return ( value === "text" || value === "number" || + value === "float" || value === "boolean" || value === "array" || value === "object" || @@ -176,6 +178,18 @@ function createIdTokenClaimItem(id: string): IdTokenClaimItem { }; } +function normalizeIdTokenClaimPermissions( + claim: IdTokenClaimItem, +): IdTokenClaimItem { + if (claim.writePermission !== "user_and_admin") { + return claim; + } + return { + ...claim, + readPermission: "user_and_admin", + }; +} + function readIdTokenClaimsMetadata( metadata: Record, ): IdTokenClaimItem[] { @@ -213,7 +227,7 @@ function readIdTokenClaimsMetadata( ? record.valueType : "text"; - return { + return normalizeIdTokenClaimPermissions({ id: `claim-${index + 1}`, namespace: namespaceValue, key: keyValue, @@ -226,7 +240,7 @@ function readIdTokenClaimsMetadata( writePermission: isCustomClaimPermission(record.writePermission) ? record.writePermission : "admin_only", - }; + }); }) .filter((item): item is IdTokenClaimItem => item !== null); } @@ -240,7 +254,7 @@ function normalizeClaimPreviewValue( if (nullable && trimmed === "") { return null; } - if (valueType === "number") { + if (valueType === "number" || valueType === "float") { if (trimmed === "") return ""; const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : trimmed; @@ -279,6 +293,137 @@ function normalizeClaimPreviewValue( 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 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 { @@ -777,10 +922,10 @@ function ClientGeneralPage() { if (claim.id !== id) { return claim; } - return { + return normalizeIdTokenClaimPermissions({ ...claim, [field]: permission, - }; + }); }), ); }; @@ -840,11 +985,13 @@ function ClientGeneralPage() { "허용 알고리즘: {{algorithms}}", { algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") }, ); - const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({ - ...claim, - key: claim.key.trim(), - value: claim.value.trim(), - })); + const normalizedIdTokenClaims = idTokenClaims.map((claim) => + normalizeIdTokenClaimPermissions({ + ...claim, + key: claim.key.trim(), + value: claim.value.trim(), + }), + ); if (headlessLoginEnabled) { if (!trimmedJwksUri) { @@ -930,6 +1077,11 @@ function ClientGeneralPage() { continue; } seenClaimKeys.add(keySignature); + + const defaultValueError = claimDefaultValueValidationError(claim); + if (defaultValueError) { + claimValidationErrors.push(defaultValueError); + } } validationErrors.push(...claimValidationErrors); @@ -2103,7 +2255,7 @@ function ClientGeneralPage() { {t( "msg.dev.clients.general.id_token_claims.subtitle", - "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.", + "RP 전용 확장 claim을 구분해서 관리합니다.", )} @@ -2151,13 +2303,13 @@ function ClientGeneralPage() { {t( "ui.dev.clients.general.id_token_claims.table.read_user_allowed", - "Read", + "User read", )} {t( "ui.dev.clients.general.id_token_claims.table.write_user_allowed", - "Write", + "User write", )} @@ -2175,190 +2327,255 @@ function ClientGeneralPage() { - {idTokenClaims.map((claim) => ( - - - - 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", - )} - - - - - - -
- + {idTokenClaims.map((claim) => { + const defaultValueError = + claimDefaultValueValidationError(claim); + + return ( + + + updateIdTokenClaim( claim.id, - "nullable", - checked, + "key", + e.target.value, ) } - aria-label={t( - "ui.dev.clients.general.id_token_claims.nullable_label", - "Nullable", + className="h-9 font-mono text-xs" + placeholder={t( + "ui.dev.clients.general.id_token_claims.key_placeholder", + "e.g. locale", )} disabled={isGeneralSettingsReadOnly} /> -
- - -
- - setIdTokenClaimPermissionAllowed( + + + + {t( + "ui.dev.clients.general.id_token_claims.namespace_rp_claims", + "rp_claims", + )} + + + + - updateIdTokenClaim( - claim.id, - "value", - e.target.value, - ) - } - className="h-9 font-mono text-xs" - placeholder={t( - "ui.dev.clients.general.id_token_claims.value_placeholder", - "Enter the default value", + > + + + + + + + + + + + +
+ + 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" ? ( +