From 8ad53f96467103bc1c3fd1656082906485339699 Mon Sep 17 00:00:00 2001 From: janithjay Date: Fri, 3 Jul 2026 12:53:10 +0530 Subject: [PATCH] Indroduce edit functionality for UserProfile component --- .../src/api/__tests__/getSchemas.test.ts | 199 ------------------ ...{getScim2Me.test.ts => getUsersMe.test.ts} | 103 ++++----- .../src/api/__tests__/updateMeProfile.test.ts | 86 +++++--- packages/javascript/src/api/getSchemas.ts | 151 ------------- .../src/api/{getScim2Me.ts => getUsersMe.ts} | 35 +-- .../javascript/src/api/updateMeProfile.ts | 38 ++-- .../src/constants/ScopeConstants.ts | 2 +- .../__tests__/ThunderIDAPIError.test.ts | 1 - packages/javascript/src/index.ts | 10 +- packages/javascript/src/models/config.ts | 13 +- .../javascript/src/models/scim2-schema.ts | 69 ------ packages/javascript/src/models/user.ts | 3 - .../javascript/src/theme/createTheme.test.ts | 34 +-- .../utils/__tests__/flattenUserSchema.test.ts | 162 -------------- .../__tests__/generateUserProfile.test.ts | 162 -------------- .../javascript/src/utils/flattenUserSchema.ts | 94 --------- .../src/utils/generateFlattenedUserProfile.ts | 172 +-------------- .../src/utils/generateUserProfile.ts | 86 -------- .../src/utils/getAuthorizeRequestUrlParams.ts | 4 +- .../javascript/src/utils/processUsername.ts | 4 +- packages/nextjs/src/ThunderIDNextClient.ts | 33 +-- .../presentation/UserProfile/UserProfile.tsx | 7 +- .../contexts/ThunderID/ThunderIDProvider.tsx | 2 +- .../nextjs/src/server/ThunderIDProvider.tsx | 3 +- .../server/actions/getUserProfileAction.ts | 1 - .../src/runtime/components/ThunderIDRoot.ts | 12 +- .../nuxt/src/runtime/errors/error-codes.ts | 6 +- .../src/runtime/server/ThunderIDNuxtClient.ts | 2 +- .../runtime/server/plugins/thunderid-ssr.ts | 8 +- .../server/routes/auth/user/profile.get.ts | 3 +- .../server/routes/auth/user/profile.patch.ts | 4 +- packages/nuxt/src/runtime/types.ts | 4 +- .../nuxt/tests/unit/thunderid-root.test.ts | 6 +- .../nuxt/tests/unit/thunderid-ssr.test.ts | 4 +- packages/react/package.json | 1 + .../src/api/{getScim2Me.ts => getUsersMe.ts} | 24 +-- packages/react/src/api/updateMeProfile.ts | 12 +- .../auth/Callback/__tests__/Callback.test.tsx | 3 +- .../Callback/__tests__/TokenCallback.test.tsx | 32 ++- .../UserProfile/BaseUserProfile.tsx | 104 +++------ .../presentation/UserProfile/UserProfile.tsx | 43 +++- .../contexts/ThunderID/ThunderIDContext.ts | 2 + .../contexts/ThunderID/ThunderIDProvider.tsx | 30 ++- .../react/src/contexts/User/UserContext.ts | 4 +- .../react/src/contexts/User/UserProvider.tsx | 2 - packages/react/src/contexts/User/useUser.ts | 1 - packages/react/src/index.ts | 4 +- packages/vue/src/ThunderIDVueClient.ts | 19 +- packages/vue/src/api/getSchemas.ts | 58 ----- .../src/api/{getScim2Me.ts => getUsersMe.ts} | 12 +- .../user-profile/BaseUserProfile.ts | 43 +--- .../presentation/user-profile/UserProfile.ts | 3 +- packages/vue/src/models/contexts.ts | 7 +- .../vue/src/providers/ThunderIDProvider.ts | 8 +- packages/vue/src/providers/UserProvider.ts | 8 +- pnpm-lock.yaml | 3 + 56 files changed, 373 insertions(+), 1573 deletions(-) delete mode 100644 packages/javascript/src/api/__tests__/getSchemas.test.ts rename packages/javascript/src/api/__tests__/{getScim2Me.test.ts => getUsersMe.test.ts} (69%) delete mode 100644 packages/javascript/src/api/getSchemas.ts rename packages/javascript/src/api/{getScim2Me.ts => getUsersMe.ts} (79%) delete mode 100644 packages/javascript/src/models/scim2-schema.ts delete mode 100644 packages/javascript/src/utils/__tests__/flattenUserSchema.test.ts delete mode 100644 packages/javascript/src/utils/__tests__/generateUserProfile.test.ts delete mode 100644 packages/javascript/src/utils/flattenUserSchema.ts delete mode 100644 packages/javascript/src/utils/generateUserProfile.ts rename packages/react/src/api/{getScim2Me.ts => getUsersMe.ts} (81%) delete mode 100644 packages/vue/src/api/getSchemas.ts rename packages/vue/src/api/{getScim2Me.ts => getUsersMe.ts} (84%) diff --git a/packages/javascript/src/api/__tests__/getSchemas.test.ts b/packages/javascript/src/api/__tests__/getSchemas.test.ts deleted file mode 100644 index 72b2393..0000000 --- a/packages/javascript/src/api/__tests__/getSchemas.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {describe, it, expect, vi, beforeEach} from 'vitest'; -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import type {Schema} from '../../models/scim2-schema'; -import getSchemas from '../getSchemas'; - -describe('getSchemas', (): void => { - beforeEach((): void => { - vi.resetAllMocks(); - }); - - it('should fetch schemas successfully (default fetch)', async (): Promise => { - const mockSchemas: Schema[] = [ - {id: 'urn:ietf:params:scim:schemas:core:2.0:User', name: 'User'} as any, - {id: 'urn:ietf:params:scim:schemas:core:2.0:Group', name: 'Group'} as any, - ]; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockSchemas), - ok: true, - }); - - const url = 'https://localhost:8090/scim2/Schemas'; - const result: Schema[] = await getSchemas({url}); - - expect(fetch).toHaveBeenCalledWith(url, { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - method: 'GET', - }); - expect(result).toEqual(mockSchemas); - }); - - it('should fall back to baseUrl when url is not provided', async (): Promise => { - const mockSchemas: Schema[] = [{id: 'core', name: 'Core'} as any]; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockSchemas), - ok: true, - }); - - const baseUrl = 'https://localhost:8090'; - const result: Schema[] = await getSchemas({baseUrl}); - - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/scim2/Schemas`, { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - method: 'GET', - }); - expect(result).toEqual(mockSchemas); - }); - - it('should use custom fetcher when provided', async (): Promise => { - const mockSchemas: Schema[] = [{id: 'ext', name: 'Extension'} as any]; - - const customFetcher: typeof fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockSchemas), - ok: true, - }); - - const baseUrl = 'https://localhost:8090'; - const result: Schema[] = await getSchemas({baseUrl, fetcher: customFetcher}); - - expect(result).toEqual(mockSchemas); - expect(customFetcher).toHaveBeenCalledWith( - `${baseUrl}/scim2/Schemas`, - expect.objectContaining({ - headers: expect.objectContaining({ - Accept: 'application/json', - 'Content-Type': 'application/json', - }), - method: 'GET', - }), - ); - }); - - it('should handle errors thrown directly by custom fetcher', async (): Promise => { - const customFetcher: typeof fetch = vi.fn().mockImplementation(() => { - throw new Error('Custom fetcher failure'); - }); - - const baseUrl = 'https://localhost:8090'; - - await expect(getSchemas({baseUrl, fetcher: customFetcher})).rejects.toThrow( - 'Network or parsing error: Custom fetcher failure', - ); - }); - - it('should throw ThunderIDAPIError for invalid URL', async (): Promise => { - await expect(getSchemas({url: 'invalid-url' as any})).rejects.toThrow(ThunderIDAPIError); - await expect(getSchemas({url: 'invalid-url' as any})).rejects.toThrow('Invalid URL provided.'); - }); - - it('should throw ThunderIDAPIError when both url and baseUrl are undefined', async (): Promise => { - await expect(getSchemas({baseUrl: undefined as any, url: undefined as any})).rejects.toThrow(ThunderIDAPIError); - await expect(getSchemas({baseUrl: undefined as any, url: undefined as any})).rejects.toThrow( - 'Invalid URL provided.', - ); - }); - - it('should throw ThunderIDAPIError when both url and baseUrl are empty strings', async (): Promise => { - await expect(getSchemas({baseUrl: '', url: ''})).rejects.toThrow(ThunderIDAPIError); - await expect(getSchemas({baseUrl: '', url: ''})).rejects.toThrow('Invalid URL provided.'); - }); - - it('should handle HTTP error responses', async (): Promise => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - text: () => Promise.resolve('Server exploded'), - }); - - const baseUrl = 'https://localhost:8090'; - - await expect(getSchemas({baseUrl})).rejects.toThrow(ThunderIDAPIError); - await expect(getSchemas({baseUrl})).rejects.toThrow('Failed to fetch SCIM2 schemas: Server exploded'); - }); - - it('should handle network or parsing errors', async (): Promise => { - global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); - - const baseUrl = 'https://localhost:8090'; - - await expect(getSchemas({baseUrl})).rejects.toThrow(ThunderIDAPIError); - await expect(getSchemas({baseUrl})).rejects.toThrow('Network or parsing error: Network error'); - }); - - it('should handle non-Error rejections gracefully', async (): Promise => { - global.fetch = vi.fn().mockRejectedValue('unexpected failure'); - - const baseUrl = 'https://localhost:8090'; - - await expect(getSchemas({baseUrl})).rejects.toThrow('Network or parsing error: Unknown error'); - }); - - it('should prefer url over baseUrl when both are provided', async (): Promise => { - const mockSchemas: Schema[] = [{id: 'x', name: 'X'} as any]; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockSchemas), - ok: true, - }); - - const url = 'https://localhost:8090/scim2/Schemas'; - const baseUrl = 'https://localhost:8090'; - await getSchemas({baseUrl, url}); - - expect(fetch).toHaveBeenCalledWith(url, expect.any(Object)); - }); - - it('should include custom headers when provided', async (): Promise => { - const mockSchemas: Schema[] = [{id: 'y', name: 'Y'} as any]; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockSchemas), - ok: true, - }); - - const baseUrl = 'https://localhost:8090'; - const customHeaders: Record = { - Authorization: 'Bearer token', - 'X-Custom-Header': 'custom-value', - }; - - await getSchemas({baseUrl, headers: customHeaders}); - - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/scim2/Schemas`, { - headers: { - Accept: 'application/json', - Authorization: 'Bearer token', - 'Content-Type': 'application/json', - 'X-Custom-Header': 'custom-value', - }, - method: 'GET', - }); - }); -}); diff --git a/packages/javascript/src/api/__tests__/getScim2Me.test.ts b/packages/javascript/src/api/__tests__/getUsersMe.test.ts similarity index 69% rename from packages/javascript/src/api/__tests__/getScim2Me.test.ts rename to packages/javascript/src/api/__tests__/getUsersMe.test.ts index d3349e3..0e8965b 100644 --- a/packages/javascript/src/api/__tests__/getScim2Me.test.ts +++ b/packages/javascript/src/api/__tests__/getUsersMe.test.ts @@ -18,40 +18,47 @@ import {describe, it, expect, vi} from 'vitest'; import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import getScim2Me from '../getScim2Me'; +import getUsersMe from '../getUsersMe'; // Mock user data -const mockUser: Record = { - email: 'test@example.com', - familyName: 'User', - givenName: 'Test', +const mockUserResponse: Record = { id: '123', - username: 'testuser', + attributes: { + email: 'test@example.com', + familyName: 'User', + givenName: 'Test', + username: 'testuser', + }, +}; + +const mockUser: Record = { + ...mockUserResponse, + ...mockUserResponse.attributes, }; -describe('getScim2Me', () => { +describe('getUsersMe', () => { it('should fetch user profile successfully with default fetch', async () => { // Mock fetch const mockFetch: typeof fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockUser), + json: () => Promise.resolve(mockUserResponse), ok: true, status: 200, statusText: 'OK', - text: () => Promise.resolve(JSON.stringify(mockUser)), + text: () => Promise.resolve(JSON.stringify(mockUserResponse)), }); // Replace global fetch global.fetch = mockFetch; - const result: Record = await getScim2Me({ - url: 'https://localhost:8090/scim2/Me', + const result: Record = await getUsersMe({ + url: 'https://localhost:8090/users/me', }); expect(result).toEqual(mockUser); - expect(mockFetch).toHaveBeenCalledWith('https://localhost:8090/scim2/Me', { + expect(mockFetch).toHaveBeenCalledWith('https://localhost:8090/users/me', { headers: { Accept: 'application/json', - 'Content-Type': 'application/scim+json', + 'Content-Type': 'application/json', }, method: 'GET', }); @@ -59,23 +66,23 @@ describe('getScim2Me', () => { it('should use custom fetcher when provided', async () => { const customFetcher: typeof fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockUser), + json: () => Promise.resolve(mockUserResponse), ok: true, status: 200, statusText: 'OK', - text: () => Promise.resolve(JSON.stringify(mockUser)), + text: () => Promise.resolve(JSON.stringify(mockUserResponse)), }); - const result: Record = await getScim2Me({ + const result: Record = await getUsersMe({ fetcher: customFetcher, - url: 'https://localhost:8090/scim2/Me', + url: 'https://localhost:8090/users/me', }); expect(result).toEqual(mockUser); - expect(customFetcher).toHaveBeenCalledWith('https://localhost:8090/scim2/Me', { + expect(customFetcher).toHaveBeenCalledWith('https://localhost:8090/users/me', { headers: { Accept: 'application/json', - 'Content-Type': 'application/scim+json', + 'Content-Type': 'application/json', }, method: 'GET', }); @@ -87,58 +94,58 @@ describe('getScim2Me', () => { }); await expect( - getScim2Me({ + getUsersMe({ fetcher: customFetcher, - url: 'https://localhost:8090/scim2/Me', + url: 'https://localhost:8090/users/me', }), ).rejects.toThrow(ThunderIDAPIError); await expect( - getScim2Me({ + getUsersMe({ fetcher: customFetcher, - url: 'https://localhost:8090/scim2/Me', + url: 'https://localhost:8090/users/me', }), ).rejects.toThrow('Network or parsing error: Custom fetcher failure'); }); it('should throw ThunderIDAPIError for invalid URL', async () => { await expect( - getScim2Me({ + getUsersMe({ url: 'invalid-url', }), ).rejects.toThrow(ThunderIDAPIError); await expect( - getScim2Me({ + getUsersMe({ baseUrl: 'invalid-url', }), ).rejects.toThrow(ThunderIDAPIError); }); it('should throw ThunderIDAPIError for undefined URL', async () => { - await expect(getScim2Me({})).rejects.toThrow(ThunderIDAPIError); + await expect(getUsersMe({})).rejects.toThrow(ThunderIDAPIError); - const error: ThunderIDAPIError = await getScim2Me({ + const error: ThunderIDAPIError = await getUsersMe({ baseUrl: undefined, url: undefined, }).catch((e: ThunderIDAPIError) => e); expect(error.name).toBe('ThunderIDAPIError'); - expect(error.code).toBe('getScim2Me-ValidationError-001'); + expect(error.code).toBe('getUsersMe-ValidationError-001'); }); it('should throw ThunderIDAPIError for empty string URL', async () => { await expect( - getScim2Me({ + getUsersMe({ url: '', }), ).rejects.toThrow(ThunderIDAPIError); - const error: ThunderIDAPIError = await getScim2Me({ + const error: ThunderIDAPIError = await getUsersMe({ url: '', }).catch((e: ThunderIDAPIError) => e); expect(error.name).toBe('ThunderIDAPIError'); - expect(error.code).toBe('getScim2Me-ValidationError-001'); + expect(error.code).toBe('getUsersMe-ValidationError-001'); }); it('should throw ThunderIDAPIError for failed response', async () => { @@ -152,8 +159,8 @@ describe('getScim2Me', () => { global.fetch = mockFetch; await expect( - getScim2Me({ - url: 'https://localhost:8090/scim2/Me', + getUsersMe({ + url: 'https://localhost:8090/users/me', }), ).rejects.toThrow(ThunderIDAPIError); }); @@ -164,8 +171,8 @@ describe('getScim2Me', () => { global.fetch = mockFetch; await expect( - getScim2Me({ - url: 'https://localhost:8090/scim2/Me', + getUsersMe({ + url: 'https://localhost:8090/users/me', }), ).rejects.toThrow(ThunderIDAPIError); }); @@ -175,17 +182,17 @@ describe('getScim2Me', () => { const baseUrl = 'https://localhost:8090'; - await expect(getScim2Me({baseUrl})).rejects.toThrow(ThunderIDAPIError); - await expect(getScim2Me({baseUrl})).rejects.toThrow('Network or parsing error: Unknown error'); + await expect(getUsersMe({baseUrl})).rejects.toThrow(ThunderIDAPIError); + await expect(getUsersMe({baseUrl})).rejects.toThrow('Network or parsing error: Unknown error'); }); it('should pass through custom headers', async () => { const mockFetch: typeof fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockUser), + json: () => Promise.resolve(mockUserResponse), ok: true, status: 200, statusText: 'OK', - text: () => Promise.resolve(JSON.stringify(mockUser)), + text: () => Promise.resolve(JSON.stringify(mockUserResponse)), }); global.fetch = mockFetch; @@ -194,15 +201,15 @@ describe('getScim2Me', () => { 'X-Custom-Header': 'custom-value', }; - await getScim2Me({ + await getUsersMe({ headers: customHeaders, - url: 'https://localhost:8090/scim2/Me', + url: 'https://localhost:8090/users/me', }); - expect(mockFetch).toHaveBeenCalledWith('https://localhost:8090/scim2/Me', { + expect(mockFetch).toHaveBeenCalledWith('https://localhost:8090/users/me', { headers: { Accept: 'application/json', - 'Content-Type': 'application/scim+json', + 'Content-Type': 'application/json', ...customHeaders, }, method: 'GET', @@ -211,22 +218,22 @@ describe('getScim2Me', () => { it('should default to baseUrl if url is not provided', async () => { const mockFetch: typeof fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockUser), + json: () => Promise.resolve(mockUserResponse), ok: true, status: 200, statusText: 'OK', - text: () => Promise.resolve(JSON.stringify(mockUser)), + text: () => Promise.resolve(JSON.stringify(mockUserResponse)), }); global.fetch = mockFetch; const baseUrl = 'https://localhost:8090'; - await getScim2Me({ + await getUsersMe({ baseUrl, }); - expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/scim2/Me`, { + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/users/me`, { headers: { Accept: 'application/json', - 'Content-Type': 'application/scim+json', + 'Content-Type': 'application/json', }, method: 'GET', }); diff --git a/packages/javascript/src/api/__tests__/updateMeProfile.test.ts b/packages/javascript/src/api/__tests__/updateMeProfile.test.ts index aef9447..13eac37 100644 --- a/packages/javascript/src/api/__tests__/updateMeProfile.test.ts +++ b/packages/javascript/src/api/__tests__/updateMeProfile.test.ts @@ -27,19 +27,26 @@ describe('updateMeProfile', (): void => { }); it('should update profile successfully using default fetch', async (): Promise => { - const mockUser: User = { - email: 'alice@example.com', + const mockUserResponse = { id: 'u1', - name: 'Alice', + attributes: { + email: 'alice@example.com', + name: 'Alice', + }, + }; + + const mockUser: User = { + ...mockUserResponse, + ...mockUserResponse.attributes, }; global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockUser), + json: () => Promise.resolve(mockUserResponse), ok: true, }); - const url = 'https://localhost:8090/scim2/Me'; - const payload: Record = {'urn:scim:wso2:schema': {mobileNumbers: ['0777933830']}}; + const url = 'https://localhost:8090/users/me'; + const payload: Record = {given_name: 'Alice'}; const result: User = await updateMeProfile({payload, url}); @@ -47,60 +54,77 @@ describe('updateMeProfile', (): void => { const [calledUrl, init]: [string, RequestInit] = (fetch as unknown as Mock).mock.calls[0]; expect(calledUrl).toBe(url); - expect(init.method).toBe('PATCH'); - expect((init.headers as Record)['Content-Type']).toBe('application/scim+json'); + expect(init.method).toBe('PUT'); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); expect((init.headers as Record)['Accept']).toBe('application/json'); const parsed: Record = JSON.parse(init.body as string); - expect(parsed.schemas).toEqual(['urn:ietf:params:scim:api:messages:2.0:PatchOp']); - expect(parsed.Operations).toEqual([{op: 'replace', value: payload}]); + expect(parsed.attributes).toEqual(payload); expect(result).toEqual(mockUser); }); it('should fall back to baseUrl when url is not provided', async (): Promise => { - const mockUser: User = { - email: 'bob@example.com', + const mockUserResponse = { id: 'u2', - name: 'Bob', + attributes: { + email: 'bob@example.com', + name: 'Bob', + }, + }; + + const mockUser: User = { + ...mockUserResponse, + ...mockUserResponse.attributes, }; global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockUser), + json: () => Promise.resolve(mockUserResponse), ok: true, }); const baseUrl = 'https://localhost:8090'; - const payload: Record = {profile: {givenName: 'Bob'}}; + const payload: Record = {givenName: 'Bob'}; const result: User = await updateMeProfile({baseUrl, payload}); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/scim2/Me`, expect.any(Object)); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/users/me`, expect.any(Object)); expect(result).toEqual(mockUser); }); it('should use custom fetcher when provided', async (): Promise => { - const mockUser: User = {email: 'carol@example.com', id: 'u3', name: 'Carol'}; + const mockUserResponse = { + id: 'u3', + attributes: { + email: 'carol@example.com', + name: 'Carol', + }, + }; + + const mockUser: User = { + ...mockUserResponse, + ...mockUserResponse.attributes, + }; const customFetcher: Mock = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockUser), + json: () => Promise.resolve(mockUserResponse), ok: true, }); const baseUrl = 'https://localhost:8090'; - const payload: Record = {profile: {familyName: 'Doe'}}; + const payload: Record = {familyName: 'Doe'}; const result: User = await updateMeProfile({baseUrl, fetcher: customFetcher, payload}); expect(result).toEqual(mockUser); expect(customFetcher).toHaveBeenCalledWith( - `${baseUrl}/scim2/Me`, + `${baseUrl}/users/me`, expect.objectContaining({ headers: expect.objectContaining({ Accept: 'application/json', - 'Content-Type': 'application/scim+json', + 'Content-Type': 'application/json', }), - method: 'PATCH', + method: 'PUT', }), ); }); @@ -112,7 +136,7 @@ describe('updateMeProfile', (): void => { ok: true, }); - const url = 'https://localhost:8090/scim2/Me'; + const url = 'https://localhost:8090/users/me'; const baseUrl = 'https://localhost:8090'; await updateMeProfile({baseUrl, payload: {x: 1}, url}); @@ -156,16 +180,16 @@ describe('updateMeProfile', (): void => { it('should handle network or unknown errors with the generic message', async (): Promise => { // Rejection with Error global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); - await expect(updateMeProfile({payload: {a: 1}, url: 'https://localhost:8090/scim2/Me'})).rejects.toThrow( + await expect(updateMeProfile({payload: {a: 1}, url: 'https://localhost:8090/users/me'})).rejects.toThrow( ThunderIDAPIError, ); - await expect(updateMeProfile({payload: {a: 1}, url: 'https://localhost:8090/scim2/Me'})).rejects.toThrow( + await expect(updateMeProfile({payload: {a: 1}, url: 'https://localhost:8090/users/me'})).rejects.toThrow( 'An error occurred while updating the user profile. Please try again.', ); // Rejection with non-Error global.fetch = vi.fn().mockRejectedValue('weird failure'); - await expect(updateMeProfile({payload: {a: 1}, url: 'https://localhost:8090/scim2/Me'})).rejects.toThrow( + await expect(updateMeProfile({payload: {a: 1}, url: 'https://localhost:8090/users/me'})).rejects.toThrow( 'An error occurred while updating the user profile. Please try again.', ); }); @@ -192,28 +216,26 @@ describe('updateMeProfile', (): void => { expect((init as Record).headers).toMatchObject({ Accept: 'application/json', Authorization: 'Bearer token', - 'Content-Type': 'application/scim+json', + 'Content-Type': 'application/json', 'X-Custom-Header': 'custom-value', }); }); - it('should build the SCIM PatchOp body correctly', async (): Promise => { + it('should build the PUT attributes body correctly', async (): Promise => { global.fetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({} as User), ok: true, }); const baseUrl = 'https://localhost:8090'; - const payload: Record = {'urn:scim:wso2:schema': {mobileNumbers: ['123']}}; + const payload: Record = {mobileNumbers: ['123']}; await updateMeProfile({baseUrl, payload}); const [, init]: [string, RequestInit] = (fetch as unknown as Mock).mock.calls[0]; const body: Record = JSON.parse((init as Record).body as string); - expect(body.schemas).toEqual(['urn:ietf:params:scim:api:messages:2.0:PatchOp']); - expect(body.Operations).toHaveLength(1); - expect((body.Operations as Record[])[0]).toEqual({op: 'replace', value: payload}); + expect(body.attributes).toEqual(payload); }); it('should allow method override when provided in requestConfig', async (): Promise => { diff --git a/packages/javascript/src/api/getSchemas.ts b/packages/javascript/src/api/getSchemas.ts deleted file mode 100644 index 48cbe0f..0000000 --- a/packages/javascript/src/api/getSchemas.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {Schema} from '../models/scim2-schema'; - -/** - * Configuration for the getSchemas request - */ -export interface GetSchemasConfig extends Omit { - /** - * The base path of the API endpoint. - */ - baseUrl?: string; - /** - * Optional custom fetcher function. - * If not provided, native fetch will be used - */ - fetcher?: (url: string, config: RequestInit) => Promise; - /** - * The absolute API endpoint. - */ - url?: string; -} - -/** - * Retrieves the SCIM2 schemas from the specified endpoint. - * - * @param config - Request configuration object. - * @returns A promise that resolves with the SCIM2 schemas information. - * @example - * ```typescript - * // Using default fetch - * try { - * const schemas = await getSchemas({ - * url: "https://localhost:8090/scim2/Schemas", - * }); - * console.log(schemas); - * } catch (error) { - * if (error instanceof ThunderIDAPIError) { - * console.error('Failed to get schemas:', error.message); - * } - * } - * ``` - * - * @example - * ```typescript - * // Using custom fetcher (e.g., axios-based httpClient) - * try { - * const schemas = await getSchemas({ - * url: "https://localhost:8090/scim2/Schemas", - * fetcher: async (url, config) => { - * const response = await httpClient({ - * url, - * method: config.method, - * headers: config.headers, - * ...config - * }); - * // Convert axios-like response to fetch-like Response - * return { - * ok: response.status >= 200 && response.status < 300, - * status: response.status, - * statusText: response.statusText, - * json: () => Promise.resolve(response.data), - * text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)) - * } as Response; - * } - * }); - * console.log(schemas); - * } catch (error) { - * if (error instanceof ThunderIDAPIError) { - * console.error('Failed to get schemas:', error.message); - * } - * } - * ``` - */ -const getSchemas = async ({url, baseUrl, fetcher, ...requestConfig}: GetSchemasConfig): Promise => { - try { - // eslint-disable-next-line no-new - new URL((url ?? baseUrl)!); - } catch (error) { - throw new ThunderIDAPIError( - `Invalid URL provided. ${error?.toString()}`, - 'getSchemas-ValidationError-001', - 'javascript', - 400, - 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', - ); - } - - const fetchFn: typeof fetch = fetcher || fetch; - const resolvedUrl: string = url ?? `${baseUrl}/scim2/Schemas`; - - const requestInit: RequestInit = { - ...requestConfig, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...requestConfig.headers, - }, - method: 'GET', - }; - - try { - const response: Response = await fetchFn(resolvedUrl, requestInit); - - if (!response?.ok) { - const errorText: string = await response.text(); - - throw new ThunderIDAPIError( - errorText, - 'getSchemas-ResponseError-001', - 'javascript', - response.status, - response.statusText, - 'Failed to fetch SCIM2 schemas', - ); - } - - return (await response.json()) as Schema[]; - } catch (error) { - if (error instanceof ThunderIDAPIError) { - throw error; - } - - throw new ThunderIDAPIError( - `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'getSchemas-NetworkError-001', - 'javascript', - 0, - 'Network Error', - ); - } -}; - -export default getSchemas; diff --git a/packages/javascript/src/api/getScim2Me.ts b/packages/javascript/src/api/getUsersMe.ts similarity index 79% rename from packages/javascript/src/api/getScim2Me.ts rename to packages/javascript/src/api/getUsersMe.ts index 4c226cd..4eff973 100644 --- a/packages/javascript/src/api/getScim2Me.ts +++ b/packages/javascript/src/api/getUsersMe.ts @@ -21,9 +21,9 @@ import {User} from '../models/user'; import processUserUsername from '../utils/processUsername'; /** - * Configuration for the getScim2Me request + * Configuration for the getUsersMe request */ -export interface GetScim2MeConfig extends Omit { +export interface GetUsersMeConfig extends Omit { /** * The base path of the API endpoint. */ @@ -40,7 +40,7 @@ export interface GetScim2MeConfig extends Omit { } /** - * Retrieves the user profile information from the specified SCIM2 /Me endpoint. + * Retrieves the user profile information from the specified /users/me endpoint. * * @param config - Request configuration object. * @returns A promise that resolves with the user profile information. @@ -48,8 +48,8 @@ export interface GetScim2MeConfig extends Omit { * ```typescript * // Using default fetch * try { - * const userProfile = await getScim2Me({ - * url: "https://localhost:8090/scim2/Me", + * const userProfile = await getUsersMe({ + * url: "https://localhost:8090/users/me", * }); * console.log(userProfile); * } catch (error) { @@ -63,8 +63,8 @@ export interface GetScim2MeConfig extends Omit { * ```typescript * // Using custom fetcher (e.g., axios-based httpClient) * try { - * const userProfile = await getScim2Me({ - * url: "https://localhost:8090/scim2/Me", + * const userProfile = await getUsersMe({ + * url: "https://localhost:8090/users/me", * fetcher: async (url, config) => { * const response = await httpClient({ * url, @@ -90,14 +90,14 @@ export interface GetScim2MeConfig extends Omit { * } * ``` */ -const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeConfig): Promise => { +const getUsersMe = async ({url, baseUrl, fetcher, ...requestConfig}: GetUsersMeConfig): Promise => { try { // eslint-disable-next-line no-new new URL((url ?? baseUrl)!); } catch (error) { throw new ThunderIDAPIError( `Invalid URL provided. ${error?.toString()}`, - 'getScim2Me-ValidationError-001', + 'getUsersMe-ValidationError-001', 'javascript', 400, 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', @@ -105,13 +105,13 @@ const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeC } const fetchFn: typeof fetch = fetcher || fetch; - const resolvedUrl: string = url ?? `${baseUrl}/scim2/Me`; + const resolvedUrl: string = url ?? `${baseUrl}/users/me`; const requestInit: RequestInit = { ...requestConfig, headers: { Accept: 'application/json', - 'Content-Type': 'application/scim+json', + 'Content-Type': 'application/json', ...requestConfig.headers, }, method: 'GET', @@ -125,7 +125,7 @@ const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeC throw new ThunderIDAPIError( errorText, - 'getScim2Me-ResponseError-001', + 'getUsersMe-ResponseError-001', 'javascript', response.status, response.statusText, @@ -134,8 +134,13 @@ const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeC } const user: User = (await response.json()) as User; + const attributes: Record = (user['attributes'] as Record) ?? {}; + const processedUser: User = { + ...user, + ...attributes, + }; - return processUserUsername(user); + return processUserUsername(processedUser); } catch (error) { if (error instanceof ThunderIDAPIError) { throw error; @@ -143,7 +148,7 @@ const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeC throw new ThunderIDAPIError( `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'getScim2Me-NetworkError-001', + 'getUsersMe-NetworkError-001', 'javascript', 0, 'Network Error', @@ -151,4 +156,4 @@ const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeC } }; -export default getScim2Me; +export default getUsersMe; diff --git a/packages/javascript/src/api/updateMeProfile.ts b/packages/javascript/src/api/updateMeProfile.ts index 40816f1..0807b0f 100644 --- a/packages/javascript/src/api/updateMeProfile.ts +++ b/packages/javascript/src/api/updateMeProfile.ts @@ -34,7 +34,7 @@ export interface UpdateMeProfileConfig extends Omit Promise; /** - * The value object to patch (SCIM2 PATCH value) + * The value object to patch (PATCH value) */ payload: any; /** @@ -44,7 +44,7 @@ export interface UpdateMeProfileConfig extends Omit { * const response = await httpClient({ * url, @@ -103,27 +103,21 @@ const updateMeProfile = async ({ ); } - const data: Record = { - Operations: [ - { - op: 'replace', - value: payload, - }, - ], - schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], + const data = { + attributes: payload, }; const fetchFn: typeof fetch = fetcher || fetch; - const resolvedUrl: string = url ?? `${baseUrl}/scim2/Me`; + const resolvedUrl: string = url ?? `${baseUrl}/users/me`; const requestInit: RequestInit = { - method: 'PATCH', ...requestConfig, + method: 'PUT', body: JSON.stringify(data), headers: { ...requestConfig.headers, Accept: 'application/json', - 'Content-Type': 'application/scim+json', + 'Content-Type': 'application/json', }, }; @@ -143,11 +137,17 @@ const updateMeProfile = async ({ ); } - // Match the read path (`getScim2Me`) — strip the userstore prefix + // Match the read path (`getUsersMe`) — strip the userstore prefix // (e.g. "DEFAULT/") so consumers receive a clean `userName`. Without // this, the optimistic-update path would put the prefixed value into // local state and the UI would flip to "DEFAULT/" after a save. - return processUserUsername((await response.json()) as User); + const user: User = (await response.json()) as User; + const attributes: Record = (user['attributes'] as Record) ?? {}; + const processedUser: User = { + ...user, + ...attributes, + }; + return processUserUsername(processedUser); } catch (error) { if (error instanceof ThunderIDAPIError) { throw error; diff --git a/packages/javascript/src/constants/ScopeConstants.ts b/packages/javascript/src/constants/ScopeConstants.ts index 88ce28a..3ff4012 100644 --- a/packages/javascript/src/constants/ScopeConstants.ts +++ b/packages/javascript/src/constants/ScopeConstants.ts @@ -41,7 +41,7 @@ const ScopeConstants: { PROFILE: string; } = { /** - * The scope for accessing the user's profile information from SCIM. + * The scope for accessing the user's profile information. * This scope allows the client to retrieve basic user information such as * name, email, profile picture, etc. */ diff --git a/packages/javascript/src/errors/__tests__/ThunderIDAPIError.test.ts b/packages/javascript/src/errors/__tests__/ThunderIDAPIError.test.ts index 5695dd2..aa2d644 100644 --- a/packages/javascript/src/errors/__tests__/ThunderIDAPIError.test.ts +++ b/packages/javascript/src/errors/__tests__/ThunderIDAPIError.test.ts @@ -110,7 +110,6 @@ describe('ThunderIDAPIError — structured response body parsing', (): void => { const errorText: string = JSON.stringify({ code: 'SSE-5000', description: {defaultValue: 'An unexpected error occurred', key: 'error.desc'}, - message: {defaultValue: 'Internal server error', key: 'error.msg'}, }); const error: ThunderIDAPIError = new ThunderIDAPIError( errorText, diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 3a3a33b..b880837 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -31,10 +31,8 @@ export type { OrganizationUnitListResponse, } from './api/getOrganizationUnitChildren'; export {default as getUserInfo} from './api/getUserInfo'; -export {default as getScim2Me} from './api/getScim2Me'; -export type {GetScim2MeConfig} from './api/getScim2Me'; -export {default as getSchemas} from './api/getSchemas'; -export type {GetSchemasConfig} from './api/getSchemas'; +export {default as getUsersMe} from './api/getUsersMe'; +export type {GetUsersMeConfig} from './api/getUsersMe'; export {default as updateMeProfile} from './api/updateMeProfile'; export type {UpdateMeProfileConfig} from './api/updateMeProfile'; @@ -154,8 +152,6 @@ export type {User, UserProfile} from './models/user'; export type {SessionData} from './models/session'; export type {TranslationFn} from './models/translation'; export type {ResolveFlowTemplateLiteralsOptions} from './models/vars'; -export {WellKnownSchemaIds} from './models/scim2-schema'; -export type {Schema, SchemaAttribute, FlattenedSchema} from './models/scim2-schema'; export type {RecursivePartial} from './models/utility-types'; export {FieldType} from './models/field'; @@ -174,8 +170,6 @@ export {default as deepMerge} from './utils/deepMerge'; export {default as extractUserClaimsFromIdToken} from './utils/extractUserClaimsFromIdToken'; export {default as isRecognizedBaseUrlPattern} from './utils/isRecognizedBaseUrlPattern'; export {default as extractPkceStorageKeyFromState} from './utils/extractPkceStorageKeyFromState'; -export {default as flattenUserSchema} from './utils/flattenUserSchema'; -export {default as generateUserProfile} from './utils/generateUserProfile'; export {default as getLatestStateParam} from './utils/getLatestStateParam'; export {default as generateFlattenedUserProfile} from './utils/generateFlattenedUserProfile'; export {default as getRedirectBasedSignUpUrl} from './utils/getRedirectBasedSignUpUrl'; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index d107a47..8f47e8c 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -519,14 +519,13 @@ export interface I18nPreferences { export interface UserPreferences { /** - * Whether to automatically fetch the user profile from SCIM2 endpoints after sign-in. - * When set to false, the SDK will not make API calls to `/scim2/Me` and `/scim2/Schemas`. - * Instead, it will extract basic user claims from the ID token. + * Whether to automatically fetch the user profile from the `/users/me` endpoint after sign-in. + * When enabled, the SDK merges the server-backed profile with the ID token claims. + * When set to false, the SDK resolves attributes only from the OIDC ID token and the + * profile UI should be treated as read-only. * @default true - * @remarks Disabling this will improve performance but provide limited user profile information. - * Only the claims present in the ID token will be available (e.g., sub, email, name). - * For full user profile attributes (custom claims, enterprise attributes, etc.), - * keep this enabled or manually call `getUserProfile()` when needed. + * @remarks Disabling this avoids the `/users/me` request, but only the claims present + * in the ID token will be available (e.g. sub, email, name). */ fetchUserProfile?: boolean; } diff --git a/packages/javascript/src/models/scim2-schema.ts b/packages/javascript/src/models/scim2-schema.ts deleted file mode 100644 index add62a7..0000000 --- a/packages/javascript/src/models/scim2-schema.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export interface SchemaAttribute { - caseExact: boolean; - description?: string; - displayName?: string; - displayOrder?: string; - multiValued: boolean; - mutability: string; - name: string; - regEx?: string; - required?: boolean; - returned: string; - sharedProfileValueResolvingMethod?: string; - subAttributes?: SchemaAttribute[]; - supportedByDefault?: string; - type: string; - uniqueness: string; -} - -/** - * Represents a SCIM2 schema definition - */ -export interface Schema { - /** Schema attributes */ - attributes: SchemaAttribute[]; - /** Schema description */ - description: string; - /** Schema identifier */ - id: string; - /** Schema name */ - name: string; -} - -export interface FlattenedSchema extends Schema { - schemaId: string; -} - -/** - * Well-known SCIM2 schema IDs - */ -export enum WellKnownSchemaIds { - /** Core Schema */ - Core = 'urn:ietf:params:scim:schemas:core:2.0', - /** Custom User Schema */ - CustomUser = 'urn:scim:schemas:extension:custom:User', - /** Enterprise User Schema */ - EnterpriseUser = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', - /** System User Schema */ - SystemUser = 'urn:scim:wso2:schema', - /** User Schema */ - User = 'urn:ietf:params:scim:schemas:core:2.0:User', -} diff --git a/packages/javascript/src/models/user.ts b/packages/javascript/src/models/user.ts index ec61d4e..f4437e6 100644 --- a/packages/javascript/src/models/user.ts +++ b/packages/javascript/src/models/user.ts @@ -16,8 +16,6 @@ * under the License. */ -import {Schema} from './scim2-schema'; - export interface KnownUser { displayName?: string; email?: string; @@ -33,5 +31,4 @@ export interface User extends KnownUser { export interface UserProfile { flattenedProfile: User; profile: User; - schemas: Schema[]; } diff --git a/packages/javascript/src/theme/createTheme.test.ts b/packages/javascript/src/theme/createTheme.test.ts index f7667be..cc06033 100644 --- a/packages/javascript/src/theme/createTheme.test.ts +++ b/packages/javascript/src/theme/createTheme.test.ts @@ -25,21 +25,21 @@ describe('createTheme', () => { const theme: Theme = createTheme(); expect(theme.vars).toBeDefined(); - expect(theme.vars.colors.primary.main).toBe('var(--thunder-color-primary-main)'); - expect(theme.vars.colors.primary.contrastText).toBe('var(--thunder-color-primary-contrastText)'); - expect(theme.vars.spacing.unit).toBe('var(--thunder-spacing-unit)'); - expect(theme.vars.borderRadius.small).toBe('var(--thunder-border-radius-small)'); - expect(theme.vars.shadows.medium).toBe('var(--thunder-shadow-medium)'); + expect(theme.vars.colors.primary.main).toBe('var(--thunderid-color-primary-main)'); + expect(theme.vars.colors.primary.contrastText).toBe('var(--thunderid-color-primary-contrastText)'); + expect(theme.vars.spacing.unit).toBe('var(--thunderid-spacing-unit)'); + expect(theme.vars.borderRadius.small).toBe('var(--thunderid-border-radius-small)'); + expect(theme.vars.shadows.medium).toBe('var(--thunderid-shadow-medium)'); }); it('should have matching structure between cssVariables and vars', () => { const theme: Theme = createTheme(); // Check that cssVariables has corresponding entries for vars - expect(theme.cssVariables['--thunder-color-primary-main']).toBeDefined(); - expect(theme.cssVariables['--thunder-spacing-unit']).toBeDefined(); - expect(theme.cssVariables['--thunder-border-radius-small']).toBeDefined(); - expect(theme.cssVariables['--thunder-shadow-medium']).toBeDefined(); + expect(theme.cssVariables['--thunderid-color-primary-main']).toBeDefined(); + expect(theme.cssVariables['--thunderid-spacing-unit']).toBeDefined(); + expect(theme.cssVariables['--thunderid-border-radius-small']).toBeDefined(); + expect(theme.cssVariables['--thunderid-shadow-medium']).toBeDefined(); }); it('should work with custom theme configurations', () => { @@ -52,19 +52,19 @@ describe('createTheme', () => { }); // vars should still reference CSS variables, not the actual values - expect(customTheme.vars.colors.primary.main).toBe('var(--thunder-color-primary-main)'); + expect(customTheme.vars.colors.primary.main).toBe('var(--thunderid-color-primary-main)'); // but cssVariables should have the custom value - expect(customTheme.cssVariables['--thunder-color-primary-main']).toBe('#custom-color'); + expect(customTheme.cssVariables['--thunderid-color-primary-main']).toBe('#custom-color'); }); it('should work with dark theme', () => { const darkTheme: Theme = createTheme({}, true); - expect(darkTheme.vars.colors.primary.main).toBe('var(--thunder-color-primary-main)'); - expect(darkTheme.vars.colors.background.surface).toBe('var(--thunder-color-background-surface)'); + expect(darkTheme.vars.colors.primary.main).toBe('var(--thunderid-color-primary-main)'); + expect(darkTheme.vars.colors.background.surface).toBe('var(--thunderid-color-background-surface)'); // Should have dark theme values in cssVariables - expect(darkTheme.cssVariables['--thunder-color-background-surface']).toBe('#121212'); + expect(darkTheme.cssVariables['--thunderid-color-background-surface']).toBe('#121212'); }); it('should use custom CSS variable prefix when provided', () => { @@ -86,14 +86,14 @@ describe('createTheme', () => { expect(customTheme.vars.spacing.unit).toBe('var(--custom-app-spacing-unit)'); // Should not have old thunderid prefixed variables - expect(customTheme.cssVariables['--thunder-color-primary-main']).toBeUndefined(); + expect(customTheme.cssVariables['--thunderid-color-primary-main']).toBeUndefined(); }); it('should use VendorConstants.VENDOR_PREFIX as default prefix', () => { const theme: Theme = createTheme(); // Should use default prefix from VendorConstants - expect(theme.cssVariables['--thunder-color-primary-main']).toBeDefined(); - expect(theme.vars.colors.primary.main).toBe('var(--thunder-color-primary-main)'); + expect(theme.cssVariables['--thunderid-color-primary-main']).toBeDefined(); + expect(theme.vars.colors.primary.main).toBe('var(--thunderid-color-primary-main)'); }); }); diff --git a/packages/javascript/src/utils/__tests__/flattenUserSchema.test.ts b/packages/javascript/src/utils/__tests__/flattenUserSchema.test.ts deleted file mode 100644 index 2547d8a..0000000 --- a/packages/javascript/src/utils/__tests__/flattenUserSchema.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {describe, it, expect} from 'vitest'; -import type {Schema, SchemaAttribute} from '../../models/scim2-schema'; -import flattenUserSchema from '../flattenUserSchema'; - -const baseAttr = (overrides: Partial = {}): SchemaAttribute => ({ - caseExact: false, - multiValued: false, - mutability: 'readWrite', - name: 'attr', - returned: 'default', - type: 'string', - uniqueness: 'none', - ...overrides, -}); - -const baseSchema = (overrides: Partial = {}): Schema => ({ - attributes: [], - description: 'User schema', - id: 'urn:ietf:params:scim:schemas:core:2.0:User', - name: 'User', - ...overrides, -}); - -describe('flattenUserSchema', () => { - it('should return empty array when input is empty', () => { - expect(flattenUserSchema([])).toEqual([]); - }); - - it('should ignore schemas with missing/undefined attributes', () => { - const schema: Schema = baseSchema({attributes: undefined as any}); - expect(flattenUserSchema([schema])).toEqual([]); - }); - - it('should flatten simple (non-complex) top-level attributes directly', () => { - const schema: Schema = baseSchema({ - attributes: [baseAttr({name: 'userName'}), baseAttr({name: 'active', type: 'boolean'})], - }); - - const out: ReturnType = flattenUserSchema([schema]); - - expect(out).toEqual([ - expect.objectContaining({name: 'userName', schemaId: schema.id}), - expect.objectContaining({name: 'active', schemaId: schema.id}), - ]); - // Ensure other props are preserved - expect(out[0]).toMatchObject({multiValued: false, type: 'string'}); - expect(out[1]).toMatchObject({multiValued: false, type: 'boolean'}); - }); - - it('should flatten complex attributes into dot-notation (includes only sub-attributes, not the parent)', () => { - const schema: Schema = baseSchema({ - attributes: [ - baseAttr({ - name: 'name', - subAttributes: [baseAttr({name: 'givenName'}), baseAttr({name: 'familyName'})], - type: 'complex', - }), - ], - }); - - const out: ReturnType = flattenUserSchema([schema]); - expect(out).toEqual([ - expect.objectContaining({name: 'name.givenName', schemaId: schema.id}), - expect.objectContaining({name: 'name.familyName', schemaId: schema.id}), - ]); - - const names: string[] = out.map((a: {name: string}) => a.name); - expect(names).not.toContain('name'); - }); - - it('should drop complex attributes with an empty subAttributes array (no parent emitted)', () => { - const schema: Schema = baseSchema({ - attributes: [ - baseAttr({ - name: 'address', - subAttributes: [], // empty — nothing should be emitted - type: 'complex', - }), - ], - }); - - expect(flattenUserSchema([schema])).toEqual([]); - }); - - it('should handle deeper nesting by only including leaf sub-attributes (one level processed)', () => { - const schema: Schema = baseSchema({ - attributes: [ - baseAttr({ - name: 'profile', - subAttributes: [ - baseAttr({ - name: 'contact', - subAttributes: [baseAttr({name: 'email'}), baseAttr({name: 'phone', type: 'string'})], - type: 'complex', - }), - baseAttr({name: 'nickname'}), - ], - type: 'complex', - }), - ], - }); - - const out: ReturnType = flattenUserSchema([schema]); - expect(out.map((a: {name: string}) => a.name)).toEqual(['profile.contact', 'profile.nickname']); - out.forEach((a: {schemaId?: string}) => expect(a.schemaId).toBe(schema.id)); - }); - - it('should support multiple schemas and tags each flattened attribute with the correct schemaId', () => { - const userSchema: Schema = baseSchema({ - attributes: [ - baseAttr({name: 'userName'}), - baseAttr({ - name: 'name', - subAttributes: [baseAttr({name: 'givenName'})], - type: 'complex', - }), - ], - id: 'urn:user', - }); - - const groupSchema: Schema = baseSchema({ - attributes: [ - baseAttr({name: 'displayName'}), - baseAttr({ - name: 'owner', - subAttributes: [baseAttr({name: 'value'})], - type: 'complex', - }), - ], - description: 'Group schema', - id: 'urn:group', - name: 'Group', - }); - - const out: ReturnType = flattenUserSchema([userSchema, groupSchema]); - - expect(out).toEqual([ - expect.objectContaining({name: 'userName', schemaId: 'urn:user'}), - expect.objectContaining({name: 'name.givenName', schemaId: 'urn:user'}), - expect.objectContaining({name: 'displayName', schemaId: 'urn:group'}), - expect.objectContaining({name: 'owner.value', schemaId: 'urn:group'}), - ]); - }); -}); diff --git a/packages/javascript/src/utils/__tests__/generateUserProfile.test.ts b/packages/javascript/src/utils/__tests__/generateUserProfile.test.ts deleted file mode 100644 index 46eb7bc..0000000 --- a/packages/javascript/src/utils/__tests__/generateUserProfile.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {describe, it, expect} from 'vitest'; -import generateUserProfile from '../generateUserProfile'; - -describe('generateUserProfile', () => { - it('should extract simple fields present in the ME response', () => { - const me: Record = {country: 'US', userName: 'john.doe'}; - const schemas: Record[] = [ - {multiValued: false, name: 'userName', type: 'STRING'}, - {multiValued: false, name: 'country', type: 'STRING'}, - ]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out['userName']).toBe('john.doe'); - expect(out['country']).toBe('US'); - }); - - it('should support dotted paths using get() and sets nested keys using set()', () => { - const me: Record = {name: {familyName: 'Doe', givenName: 'John'}}; - const schemas: Record[] = [ - {multiValued: false, name: 'name.givenName', type: 'STRING'}, - {multiValued: false, name: 'name.familyName', type: 'STRING'}, - ]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out['name'].givenName).toBe('John'); - expect(out['name'].familyName).toBe('Doe'); - }); - - it('should wrap a single value into an array for multiValued attributes', () => { - const me: Record = {emails: 'john@example.com'}; - const schemas: Record[] = [{multiValued: true, name: 'emails', type: 'STRING'}]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out['emails']).toEqual(['john@example.com']); - }); - - it('should preserve arrays for multiValued attributes', () => { - const me: Record = {emails: ['a@x.com', 'b@x.com']}; - const schemas: Record[] = [{multiValued: true, name: 'emails', type: 'STRING'}]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out['emails']).toEqual(['a@x.com', 'b@x.com']); - }); - - it('should default missing STRING (non-multiValued) to empty string', () => { - const me: Record = {}; - const schemas: Record[] = [{multiValued: false, name: 'displayName', type: 'STRING'}]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out['displayName']).toBe(''); - }); - - it('should leave missing non-STRING (non-multiValued) as undefined', () => { - const me: Record = {}; - const schemas: Record[] = [ - {multiValued: false, name: 'age', type: 'NUMBER'}, - {multiValued: false, name: 'isActive', type: 'BOOLEAN'}, - ]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out).toHaveProperty('age'); - expect(out['age']).toBeUndefined(); - expect(out).toHaveProperty('isActive'); - expect(out['isActive']).toBeUndefined(); - }); - - it('should leave missing multiValued attributes as undefined', () => { - const me: Record = {}; - const schemas: Record[] = [{multiValued: true, name: 'groups', type: 'STRING'}]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out).toHaveProperty('groups'); - expect(out['groups']).toBeUndefined(); - }); - - it('should ignore schema entries without a name', () => { - const me: Record = {userName: 'john'}; - const schemas: Record[] = [ - {multiValued: false, name: 'userName', type: 'STRING'}, - {multiValued: false, type: 'STRING'}, - ]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out['userName']).toBe('john'); - expect(Object.keys(out).sort()).toEqual(['userName']); - }); - - it('should not mutate the source ME response', () => { - const me: Record = {emails: 'a@x.com', userName: 'john'}; - const snapshot: Record = JSON.parse(JSON.stringify(me)); - const schemas: Record[] = [ - {multiValued: false, name: 'userName', type: 'STRING'}, - {multiValued: true, name: 'emails', type: 'STRING'}, - {multiValued: false, name: 'missingStr', type: 'STRING'}, - ]; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const out: Record = generateUserProfile(me, schemas); - - expect(me).toEqual(snapshot); - }); - - it('should preserve explicit null values (only undefined triggers defaults)', () => { - const me: Record = {nickname: null}; - const schemas: Record[] = [{multiValued: false, name: 'nickname', type: 'STRING'}]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out['nickname']).toBeNull(); - }); - - it('should handle mixed present/missing values in one pass', () => { - const me: Record = { - emails: ['a@x.com'], - name: {givenName: 'John'}, - userName: 'john', - }; - const schemas: Record[] = [ - {multiValued: false, name: 'userName', type: 'STRING'}, - {multiValued: true, name: 'emails', type: 'STRING'}, - {multiValued: false, name: 'name.givenName', type: 'STRING'}, - {multiValued: false, name: 'name.middleName', type: 'STRING'}, - {multiValued: false, name: 'age', type: 'NUMBER'}, - {multiValued: true, name: 'groups', type: 'STRING'}, - ]; - - const out: Record = generateUserProfile(me, schemas); - - expect(out['userName']).toBe('john'); - expect(out['emails']).toEqual(['a@x.com']); - expect(out?.['name']?.givenName).toBe('John'); - expect(out?.['name']?.middleName).toBe(''); - expect(out['age']).toBeUndefined(); - expect(out['groups']).toBeUndefined(); - }); -}); diff --git a/packages/javascript/src/utils/flattenUserSchema.ts b/packages/javascript/src/utils/flattenUserSchema.ts deleted file mode 100644 index 3bb58e8..0000000 --- a/packages/javascript/src/utils/flattenUserSchema.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {Schema, FlattenedSchema} from '../models/scim2-schema'; - -/** - * Flattens nested schema attributes into a flat structure for easier processing - * - * This function processes SCIM2 schemas and creates a flattened representation by: - * - Processing sub-attributes and creating dot-notation names (e.g., 'name.givenName') - * - Adding schema ID reference to each flattened attribute - * - Preserving all original attribute properties while adding schema context - * - Only including leaf-level attributes (sub-attributes) and top-level simple attributes - * - * @param schemas - Array of SCIM2 schemas containing nested attribute structures - * @returns Array of flattened schema attributes with dot-notation names and schema references - * - * @example - * ```typescript - * const schemas = [ - * { - * id: 'urn:ietf:params:scim:schemas:core:2.0:User', - * attributes: [ - * { - * name: 'userName', - * type: 'string', - * multiValued: false - * }, - * { - * name: 'name', - * type: 'complex', - * multiValued: false, - * subAttributes: [ - * { name: 'givenName', type: 'string', multiValued: false }, - * { name: 'familyName', type: 'string', multiValued: false } - * ] - * } - * ] - * } - * ]; - * - * const flattened = flattenUserSchema(schemas); - * // Result: [ - * // { name: 'userName', type: 'string', multiValued: false, schemaId: 'urn:ietf:params:scim:schemas:core:2.0:User' }, - * // { name: 'name.givenName', type: 'string', multiValued: false, schemaId: 'urn:ietf:params:scim:schemas:core:2.0:User' }, - * // { name: 'name.familyName', type: 'string', multiValued: false, schemaId: 'urn:ietf:params:scim:schemas:core:2.0:User' } - * // ] - * ``` - */ -const flattenUserSchema = (schemas: Schema[]): FlattenedSchema[] => { - const flattenedAttributes: FlattenedSchema[] = []; - - schemas.forEach((schema: Schema) => { - if (schema.attributes && Array.isArray(schema.attributes)) { - schema.attributes.forEach((attribute: any) => { - // If the attribute has sub-attributes, only add the flattened sub-attributes - if (attribute.subAttributes && Array.isArray(attribute.subAttributes)) { - attribute.subAttributes.forEach((subAttribute: any) => { - flattenedAttributes.push({ - ...subAttribute, - name: `${attribute.name}.${subAttribute.name}`, - schemaId: schema.id, - } as unknown as FlattenedSchema); - }); - } else { - // If it's a simple attribute (no sub-attributes), add it directly - flattenedAttributes.push({ - ...attribute, - schemaId: schema.id, - } as unknown as FlattenedSchema); - } - }); - } - }); - - return flattenedAttributes; -}; - -export default flattenUserSchema; diff --git a/packages/javascript/src/utils/generateFlattenedUserProfile.ts b/packages/javascript/src/utils/generateFlattenedUserProfile.ts index e6b562f..c9b2177 100644 --- a/packages/javascript/src/utils/generateFlattenedUserProfile.ts +++ b/packages/javascript/src/utils/generateFlattenedUserProfile.ts @@ -1,176 +1,14 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import get from './get'; import {User} from '../models/user'; /** - * Generates a flattened user profile from a response object and schema definitions. - * - * This function processes user data according to schema specifications, creating - * a flat object with dot notation keys instead of nested objects. Multi-valued - * properties and type-specific defaults are handled appropriately. - * - * Additionally, any fields present in the response but not defined in the schema - * will be included to ensure no user data is lost during flattening. + * Generates a flattened user profile by returning the user profile as-is. * * @param meResponse - The response object containing user data - * @param processedSchemas - Array of schema objects defining field properties - * @param processedSchemas[].name - The field name/path for the property - * @param processedSchemas[].type - The data type of the field (e.g., 'STRING') - * @param processedSchemas[].multiValued - Whether the field can contain multiple values - * - * @returns A flattened user profile object with dot notation keys - * - * @example - * ```typescript - * const schemas = [ - * { name: 'name.givenName', type: 'STRING', multiValued: false }, - * { name: 'emails', type: 'STRING', multiValued: true } - * ]; - * const response = { - * name: { givenName: 'John' }, - * emails: 'john@example.com', - * country: 'US' // This will be included even if not in schema - * }; - * const profile = generateFlattenedUserProfile(response, schemas); - * // Result: { "name.givenName": 'John', emails: ['john@example.com'], country: 'US' } - * ``` + * @param _processedSchemas - Deprecated/unused schemas parameter + * @returns The user profile object */ -const generateFlattenedUserProfile = (meResponse: any, processedSchemas: any[]): User => { - const profile: User = {}; - - const allSchemaNames: string[] = processedSchemas.map((schema: any) => schema.name).filter(Boolean); - - // First, process all schema-defined fields - processedSchemas.forEach((schema: any) => { - const {name, type, multiValued} = schema; - - if (!name) return; - - // Skip this property if it's a parent of other flattened properties - // e.g., skip "name" if "name.givenName" or "name.familyName" exists - // skip "roles" if "roles.default" exists - const hasChildProperties: boolean = allSchemaNames.some( - (schemaName: string) => schemaName !== name && schemaName.startsWith(`${name}.`), - ); - - if (hasChildProperties) { - // Skip this parent property - return; - } - - let value: any = get(meResponse, name); - - // SCIM2 multi-valued complex attributes (phoneNumbers, emails, ims, ...) - // are stored as arrays of typed objects: [{type: "mobile", value: "..."}]. - // The schema flattens them as `.` (e.g. "phoneNumbers.mobile"), - // so when the dotted lookup misses, fall back to filtering the array by - // `type` and reading `.value`. - if (value === undefined) { - const dotIndex: number = name.indexOf('.'); - if (dotIndex > 0) { - const head: string = name.slice(0, dotIndex); - const tail: string = name.slice(dotIndex + 1); - const arr: any = meResponse[head]; - if (Array.isArray(arr)) { - const match: any = arr.find((item: any) => item?.type === tail); - if (match?.value !== undefined) { - value = match.value; - } - } - } - } - - // If value not found at top level, check within schema namespaces - if (value === undefined) { - const schemaNamespaces: string[] = [ - 'urn:ietf:params:scim:schemas:core:2.0:User', - 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', - 'urn:scim:wso2:schema', - 'urn:scim:schemas:extension:custom:User', - ]; - - schemaNamespaces.some((namespace: string) => { - if (meResponse[namespace]) { - // Try the field name directly within the namespace - if (meResponse[namespace][name] !== undefined) { - value = meResponse[namespace][name]; - return true; // Break out of some() - } - // Also try using get() for nested paths within the namespace - const nestedValue: any = get(meResponse[namespace], name); - if (nestedValue !== undefined) { - value = nestedValue; - return true; // Break out of some() - } - } - return false; - }); - } - - if (value !== undefined) { - if (multiValued && !Array.isArray(value)) { - value = [value]; - } - } else if (multiValued) { - value = undefined; - } else if (type === 'STRING') { - value = ''; - } else { - value = undefined; - } - - profile[name] = value; - }); - - // Then, include any additional fields from meResponse that aren't in the schema - // This ensures fields like 'country' are not missed if they exist in the response - const flattenObject = (obj: any, prefix = ''): void => { - if (obj && typeof obj === 'object' && !Array.isArray(obj)) { - Object.keys(obj).forEach((key: string) => { - const fullKey: string = prefix ? `${prefix}.${key}` : key; - const value: any = obj[key]; - - // Skip if this field is already processed by schema - if (Object.prototype.hasOwnProperty.call(profile, fullKey)) { - return; - } - - // Skip if this is a parent of schema-defined fields - const hasSchemaChildProperties: boolean = allSchemaNames.some((schemaName: string) => - schemaName.startsWith(`${fullKey}.`), - ); - - if (hasSchemaChildProperties) { - // Recursively process child properties - flattenObject(value, fullKey); - } else { - // Include the field as-is - profile[fullKey] = value; - } - }); - } - }; - - flattenObject(meResponse); - - return profile; +const generateFlattenedUserProfile = (meResponse: any, _processedSchemas?: any[]): User => { + return { ...meResponse }; }; export default generateFlattenedUserProfile; diff --git a/packages/javascript/src/utils/generateUserProfile.ts b/packages/javascript/src/utils/generateUserProfile.ts deleted file mode 100644 index 0921b0c..0000000 --- a/packages/javascript/src/utils/generateUserProfile.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import get from './get'; -import set from './set'; -import {User} from '../models/user'; - -/** - * Creates a profile structure from ME response based on processed schemas - * - * This function processes each schema attribute and populates the profile dynamically by: - * - Extracting values from the ME response using the schema attribute names - * - Handling multi-valued attributes by converting single values to arrays when needed - * - Setting appropriate default values based on schema type and multiValued properties - * - Using dynamic property setting to build the final profile object - * - * @param meResponse - The ME API response containing user data - * @param processedSchemas - The processed and flattened schemas with name, type, and multiValued properties - * @returns Flat profile object with dynamically populated user attributes - * - * @example - * ```typescript - * const meResponse = { - * userName: 'john.doe', - * emails: ['john@example.com', 'john.doe@work.com'], - * name: { givenName: 'John', familyName: 'Doe' } - * }; - * - * const schemas = [ - * { name: 'userName', type: 'STRING', multiValued: false }, - * { name: 'emails', type: 'STRING', multiValued: true }, - * { name: 'name.givenName', type: 'STRING', multiValued: false } - * ]; - * - * const profile = generateUserProfile(meResponse, schemas); - * // Result: { - * // userName: 'john.doe', - * // emails: ['john@example.com', 'john.doe@work.com'], - * // 'name.givenName': 'John' - * // } - * ``` - */ -const generateUserProfile = (meResponse: any, processedSchemas: any[]): User => { - const profile: User = {}; - - processedSchemas.forEach((schema: any) => { - const {name, type, multiValued} = schema; - - if (!name) return; - - let value: any = get(meResponse, name); - - if (value !== undefined) { - if (multiValued && !Array.isArray(value)) { - value = [value]; - } - } else if (multiValued) { - value = undefined; - } else if (type === 'STRING') { - value = ''; - } else { - value = undefined; - } - - set(profile, name, value); - }); - - return profile; -}; - -export default generateUserProfile; diff --git a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts index f71ad6b..b79740c 100644 --- a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts +++ b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts @@ -70,7 +70,9 @@ const getAuthorizeRequestUrlParams = ( authorizeRequestParams.set('response_type', 'code'); authorizeRequestParams.set('client_id', clientId); - authorizeRequestParams.set('scope', scopes ?? ''); + if (scopes !== undefined) { + authorizeRequestParams.set('scope', scopes); + } authorizeRequestParams.set('redirect_uri', redirectUri); if (responseMode) { diff --git a/packages/javascript/src/utils/processUsername.ts b/packages/javascript/src/utils/processUsername.ts index 1e7e8c1..5a50942 100644 --- a/packages/javascript/src/utils/processUsername.ts +++ b/packages/javascript/src/utils/processUsername.ts @@ -25,7 +25,7 @@ const USERSTORE_PREFIX_REGEX = /^[A-Z_][A-Z0-9_]*\//; /** * Removes userstore prefixes from a username if they exist. - * This is commonly used to clean usernames returned from SCIM2 endpoints + * This is commonly used to clean usernames returned from profile endpoints * that include userstore prefixes like "DEFAULT/", "ASGARDEO_USER/", "PRIMARY/", etc. * * @param username - The username string to process @@ -59,7 +59,7 @@ export const removeUserstorePrefix = (username?: string): string => { /** * Processes a user object to remove userstore prefixes from username fields. - * This is a helper function for processing user objects returned from SCIM2 endpoints. + * This is a helper function for processing user objects returned from profile endpoints. * Handles various username field variations: username, userName, and user_name. * * @param user - The user object to process diff --git a/packages/nextjs/src/ThunderIDNextClient.ts b/packages/nextjs/src/ThunderIDNextClient.ts index d27b18a..3b86f28 100644 --- a/packages/nextjs/src/ThunderIDNextClient.ts +++ b/packages/nextjs/src/ThunderIDNextClient.ts @@ -21,9 +21,7 @@ import { ThunderIDRuntimeError, AuthClientConfig, ExtendedAuthorizeRequestUrlParams, - FlattenedSchema, IdToken, - Schema, SignInOptions, SignUpOptions, Storage, @@ -32,11 +30,8 @@ import { User, UserProfile, extractUserClaimsFromIdToken, - flattenUserSchema, generateFlattenedUserProfile, - generateUserProfile, - getScim2Me, - getSchemas, + getUsersMe, updateMeProfile, } from '@thunderid/node'; import {ThunderIDNextConfig} from './models/config'; @@ -128,21 +123,14 @@ class ThunderIDNextClient e const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); const baseUrl: string | undefined = configData?.baseUrl; - const profile: User = await getScim2Me({ + const profile: User = await getUsersMe({ baseUrl, headers: { Authorization: `Bearer ${await this.getAccessToken(userId)}`, }, }); - const schemas: Schema[] = await getSchemas({ - baseUrl, - headers: { - Authorization: `Bearer ${await this.getAccessToken(userId)}`, - }, - }); - - return generateUserProfile(profile, flattenUserSchema(schemas)); + return profile; } catch (error) { return await super.getUser(resolvedSessionId); } @@ -155,32 +143,21 @@ class ThunderIDNextClient e const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); const baseUrl: string | undefined = configData?.baseUrl; - const profile: User = await getScim2Me({ + const profile: User = await getUsersMe({ baseUrl, headers: { Authorization: `Bearer ${await this.getAccessToken(userId)}`, }, }); - const schemas: Schema[] = await getSchemas({ - baseUrl, - headers: { - Authorization: `Bearer ${await this.getAccessToken(userId)}`, - }, - }); - - const processedSchemas: FlattenedSchema[] = flattenUserSchema(schemas); - return { - flattenedProfile: generateFlattenedUserProfile(profile, processedSchemas), + flattenedProfile: generateFlattenedUserProfile(profile), profile, - schemas: processedSchemas, }; } catch (error) { return { flattenedProfile: extractUserClaimsFromIdToken(await super.getDecodedIdToken(userId)), profile: extractUserClaimsFromIdToken(await super.getDecodedIdToken(userId)), - schemas: [], }; } } diff --git a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx index 9ffc966..ebdcb36 100644 --- a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx @@ -18,7 +18,7 @@ 'use client'; -import {Schema, User} from '@thunderid/node'; +import {User} from '@thunderid/node'; import {BaseUserProfile, BaseUserProfileProps, useUser} from '@thunderid/react'; import {FC, ReactElement} from 'react'; import getSessionId from '../../../../server/actions/getSessionId'; @@ -27,7 +27,7 @@ import getSessionId from '../../../../server/actions/getSessionId'; * Props for the UserProfile component. * Extends BaseUserProfileProps but makes the user prop optional since it will be obtained from useThunderID */ -export type UserProfileProps = Omit; +export type UserProfileProps = Omit; /** * UserProfile component displays the authenticated user's profile information in a @@ -53,7 +53,7 @@ export type UserProfileProps = Omit = ({...rest}: UserProfileProps): ReactElement => { - const {profile, flattenedProfile, schemas, onUpdateProfile, updateProfile} = useUser(); + const {profile, flattenedProfile, onUpdateProfile, updateProfile} = useUser(); const handleProfileUpdate = async (payload: any): Promise => { const result: {data: {user: User}; error: string; success: boolean} = await updateProfile( @@ -67,7 +67,6 @@ const UserProfile: FC = ({...rest}: UserProfileProps): ReactEl diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index 498faab..a7bbbcf 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -293,7 +293,7 @@ const ThunderIDClientProvider: FC ({ ...prev, - flattenedProfile: generateFlattenedUserProfile(payload, prev?.schemas), + flattenedProfile: generateFlattenedUserProfile(payload), profile: payload, })); }; diff --git a/packages/nextjs/src/server/ThunderIDProvider.tsx b/packages/nextjs/src/server/ThunderIDProvider.tsx index e10c6eb..2822d3d 100644 --- a/packages/nextjs/src/server/ThunderIDProvider.tsx +++ b/packages/nextjs/src/server/ThunderIDProvider.tsx @@ -115,7 +115,6 @@ const ThunderIDServerProvider: FC { const prev: UserProfile | null = userProfileState.value; userProfileState.value = prev ? { ...prev, - flattenedProfile: generateFlattenedUserProfile(payload, prev.schemas), + flattenedProfile: generateFlattenedUserProfile(payload), profile: payload, } : { - flattenedProfile: generateFlattenedUserProfile(payload, []), + flattenedProfile: generateFlattenedUserProfile(payload), profile: payload, - schemas: [], }; // Keep THUNDERID_KEY `user` ref in sync so `useThunderID().user` reflects // the update immediately. @@ -99,7 +98,7 @@ const ThunderIDRoot: Component = defineComponent({ }; /** - * SCIM2 PATCH via the `/api/auth/user/profile` Nitro route. + * profile PATCH via the `/api/auth/user/profile` Nitro route. * Signature matches `UserProvider.updateProfile` exactly. * * On success, applies an optimistic local update via `onUpdateProfile` @@ -173,7 +172,7 @@ const ThunderIDRoot: Component = defineComponent({ UserProvider, { // When fetchUserProfile is false the Nitro plugin - // skips SCIM calls, so we must also pass empty values + // skips profile fetches, so we must also pass empty values // here to keep SSR and client in sync. flattenedProfile: shouldFetchProfile ? (userProfileState.value?.flattenedProfile ?? null) @@ -181,7 +180,6 @@ const ThunderIDRoot: Component = defineComponent({ onUpdateProfile: shouldFetchProfile ? onUpdateProfile : undefined, profile: shouldFetchProfile ? userProfileState.value : null, revalidateProfile: shouldFetchProfile ? revalidateProfile : undefined, - schemas: shouldFetchProfile ? (userProfileState.value?.schemas ?? null) : null, updateProfile: shouldFetchProfile ? updateProfile : undefined, }, { diff --git a/packages/nuxt/src/runtime/errors/error-codes.ts b/packages/nuxt/src/runtime/errors/error-codes.ts index 8d3815e..c3d09af 100644 --- a/packages/nuxt/src/runtime/errors/error-codes.ts +++ b/packages/nuxt/src/runtime/errors/error-codes.ts @@ -41,7 +41,7 @@ export enum ErrorCode { TempSessionInvalid = 'session/temp-invalid', TokenExchangeFailed = 'oauth/token-exchange-failed', TokenRefreshFailed = 'oauth/token-refresh-failed', - // ── SCIM2 ────────────────────────────────────────────────────────── - UserProfileFetchFailed = 'scim2/user-profile-fetch-failed', - UserProfileUpdateFailed = 'scim2/user-profile-update-failed', + // ── User Profile ────────────────────────────────────────────────── + UserProfileFetchFailed = 'user-profile/fetch-failed', + UserProfileUpdateFailed = 'user-profile/update-failed', } diff --git a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts index d341ca3..2649c21 100644 --- a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts +++ b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts @@ -161,7 +161,7 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { override async getUserProfile(sessionId: string): Promise { const user: User = await this.getUser(sessionId); - return {flattenedProfile: user, profile: user, schemas: []}; + return {flattenedProfile: user, profile: user}; } override async updateUserProfile(config: UpdateMeProfileConfig, sessionId: string): Promise { diff --git a/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts b/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts index ed4ecdc..57bb943 100644 --- a/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts +++ b/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts @@ -48,11 +48,11 @@ function resolveCallbackUrl(event: H3Event): string { * switches `resolvedBaseUrl` to `${baseUrl}/o` when the user is acting * within an organisation. * 4. In parallel (gated by `preferences`): - * - Fetches user + SCIM2 user profile (`preferences.user.fetchUserProfile !== false`) + * - Fetches user + user profile (`preferences.user.fetchUserProfile !== false`) * 5. Writes the full {@link ThunderIDSSRData} to `event.context.thunderid.ssr` * so the Nuxt plugin can seed `useState` keys for zero-cost hydration. * - * Each fetch is individually wrapped in try/catch so a broken SCIM + * Each fetch is individually wrapped in try/catch so a broken profile * call never crashes SSR — the client layer can recover via the existing * `/api/auth/*` routes. */ @@ -158,7 +158,7 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { // Always fetch the basic user object (needed for ThunderIDAuthState.user) client.getUser(session.sessionId), - // SCIM2 user profile (flattened + schemas) + // User profile (flattened) shouldFetchProfile ? client.getUserProfile(session.sessionId) : Promise.resolve(null), ]); @@ -166,7 +166,7 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { log.debug('Failed to fetch user:', userResult.reason); } if (userProfileResult.status === 'rejected') { - log.warn('Failed to fetch user profile (SCIM2):', userProfileResult.reason); + log.warn('Failed to fetch user profile:', userProfileResult.reason); } // ── 5. Write to event context ────────────────────────────────────────── diff --git a/packages/nuxt/src/runtime/server/routes/auth/user/profile.get.ts b/packages/nuxt/src/runtime/server/routes/auth/user/profile.get.ts index 6d621c7..9abd682 100644 --- a/packages/nuxt/src/runtime/server/routes/auth/user/profile.get.ts +++ b/packages/nuxt/src/runtime/server/routes/auth/user/profile.get.ts @@ -26,8 +26,7 @@ import {useRuntimeConfig} from '#imports'; /** * GET /api/auth/user/profile * - * Returns the full SCIM2 {@link UserProfile} (with `flattenedProfile` and - * `schemas`) for the authenticated user. Used by `ThunderIDRoot.revalidateProfile` + * Returns the full {@link UserProfile} (with `flattenedProfile`) for the authenticated user. Used by `ThunderIDRoot.revalidateProfile` * to refresh client-side state after a profile update. * * Mirrors `getUserProfileAction` in the Next.js SDK. diff --git a/packages/nuxt/src/runtime/server/routes/auth/user/profile.patch.ts b/packages/nuxt/src/runtime/server/routes/auth/user/profile.patch.ts index a724342..943666d 100644 --- a/packages/nuxt/src/runtime/server/routes/auth/user/profile.patch.ts +++ b/packages/nuxt/src/runtime/server/routes/auth/user/profile.patch.ts @@ -27,10 +27,10 @@ import {useRuntimeConfig} from '#imports'; /** * PATCH /api/auth/user/profile * - * Updates the SCIM2 /Me profile for the authenticated user. + * Updates the /users/me profile for the authenticated user. * Mirrors the `updateUserProfileAction` Next.js server action. * - * Request body: {@link UpdateMeProfileConfig} (the SCIM patch payload). + * Request body: {@link UpdateMeProfileConfig} (the patch payload). * Response: `{ data: { user: User }; success: boolean; error: string }` */ export default defineEventHandler( diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index eab09b7..3bab1fa 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -56,7 +56,7 @@ export interface ThunderIDNuxtConfig { mode?: 'light' | 'dark' | 'system' | 'class' | 'branding'; }; user?: { - /** Whether to fetch the SCIM2 user profile during SSR (default: true). */ + /** Whether to fetch the user profile during SSR (default: true). */ fetchUserProfile?: boolean; }; }; @@ -134,7 +134,7 @@ export interface ThunderIDSSRData { resolvedBaseUrl: string | null; session: ThunderIDSessionPayload | null; user: User | null; - /** Flattened SCIM2 profile + raw profile + schemas (null when `preferences.user.fetchUserProfile` is false). */ + /** Flattened user profile + raw profile (null when `preferences.user.fetchUserProfile` is false). */ userProfile: UserProfile | null; } diff --git a/packages/nuxt/tests/unit/thunderid-root.test.ts b/packages/nuxt/tests/unit/thunderid-root.test.ts index d974004..569bbbf 100644 --- a/packages/nuxt/tests/unit/thunderid-root.test.ts +++ b/packages/nuxt/tests/unit/thunderid-root.test.ts @@ -71,7 +71,6 @@ vi.mock('#imports', () => ({ const MOCK_USER_PROFILE = { profile: {sub: 'user-123', email: 'test@example.com'}, flattenedProfile: {email: 'test@example.com'}, - schemas: [{name: 'urn:ietf:params:scim:schemas:core:2.0:User'}], }; const MOCK_CURRENT_ORG = {id: 'org-1', name: 'Test Org', orgHandle: 'test-org'}; @@ -186,7 +185,6 @@ describe('ThunderIDRoot component', () => { const vnode = findByType(root, UserProvider); expect(vnode!.props.profile).toEqual(MOCK_USER_PROFILE); expect(vnode!.props.flattenedProfile).toEqual(MOCK_USER_PROFILE.flattenedProfile); - expect(vnode!.props.schemas).toEqual(MOCK_USER_PROFILE.schemas); }); it('passes user callbacks to UserProvider when fetchUserProfile is enabled (default)', () => { @@ -227,7 +225,6 @@ describe('ThunderIDRoot component', () => { const vnode = findByType(root, UserProvider); expect(vnode!.props.profile).toBeNull(); expect(vnode!.props.flattenedProfile).toBeNull(); - expect(vnode!.props.schemas).toBeNull(); }); it('omits user callbacks from UserProvider when fetchUserProfile is false', () => { @@ -276,7 +273,7 @@ describe('ThunderIDRoot component', () => { const userProfileState = mockStateStore.get('thunderid:user-profile')!; expect(userProfileState.value.profile).toEqual(updatedUser); expect(userProfileState.value.flattenedProfile).toBeDefined(); - expect(generateFlattenedUserProfile).toHaveBeenCalledWith(updatedUser, MOCK_USER_PROFILE.schemas); + expect(generateFlattenedUserProfile).toHaveBeenCalledWith(updatedUser); }); it('onUpdateProfile keeps thunderid:auth user in sync', () => { @@ -308,7 +305,6 @@ describe('ThunderIDRoot component', () => { const userProfileState = mockStateStore.get('thunderid:user-profile')!; expect(userProfileState.value.profile).toEqual(freshUser); - expect(userProfileState.value.schemas).toEqual([]); }); // ── i18n preference passthrough ─────────────────────────────────────────── diff --git a/packages/nuxt/tests/unit/thunderid-ssr.test.ts b/packages/nuxt/tests/unit/thunderid-ssr.test.ts index 8643178..6e4d91c 100644 --- a/packages/nuxt/tests/unit/thunderid-ssr.test.ts +++ b/packages/nuxt/tests/unit/thunderid-ssr.test.ts @@ -42,7 +42,6 @@ const mockClient = vi.hoisted(() => ({ getUserProfile: vi.fn<(sessionId: string) => Promise>().mockResolvedValue({ profile: {sub: 'user-123', email: 'test@example.com'}, flattenedProfile: {email: 'test@example.com'}, - schemas: [], }), getMyOrganizations: vi .fn<(sessionId: string) => Promise>() @@ -140,7 +139,6 @@ describe('thunderid-ssr Nitro plugin', () => { mockClient.getUserProfile.mockResolvedValue({ profile: {sub: 'user-123', email: 'test@example.com'}, flattenedProfile: {email: 'test@example.com'}, - schemas: [], }); mockClient.getMyOrganizations.mockResolvedValue([{id: 'org-1', name: 'Test Org', orgHandle: 'test-org'}]); mockClient.getCurrentOrganization.mockResolvedValue({ @@ -281,7 +279,7 @@ describe('thunderid-ssr Nitro plugin', () => { // ── Non-fatal partial failures ──────────────────────────────────────────── it('still writes SSR data when getUserProfile throws (non-fatal)', async () => { - mockClient.getUserProfile.mockRejectedValueOnce(new Error('SCIM2 error')); + mockClient.getUserProfile.mockRejectedValueOnce(new Error('profile error')); const event = await callHandler('/', 'valid-cookie'); diff --git a/packages/react/package.json b/packages/react/package.json index 707ab20..fec3b28 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -51,6 +51,7 @@ "@testing-library/react": "catalog:", "@thunderid/eslint-plugin": "catalog:", "@thunderid/prettier-config": "catalog:", + "@vitest/browser-playwright": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/react/src/api/getScim2Me.ts b/packages/react/src/api/getUsersMe.ts similarity index 81% rename from packages/react/src/api/getScim2Me.ts rename to packages/react/src/api/getUsersMe.ts index a5e740c..a15caa2 100644 --- a/packages/react/src/api/getScim2Me.ts +++ b/packages/react/src/api/getUsersMe.ts @@ -21,14 +21,14 @@ import { HttpResponse, FetchHttpClient, HttpRequestConfig, - getScim2Me as baseGetScim2Me, - GetScim2MeConfig as BaseGetScim2MeConfig, + getUsersMe as baseGetUsersMe, + GetUsersMeConfig as BaseGetUsersMeConfig, } from '@thunderid/browser'; /** - * Configuration for the getScim2Me request (React-specific) + * Configuration for the getUsersMe request (React-specific) */ -export interface GetScim2MeConfig extends Omit { +export interface GetUsersMeConfig extends Omit { /** * Optional custom fetcher function. If not provided, the ThunderID SPA client's httpClient will be used * which is a wrapper around axios http.request @@ -41,7 +41,7 @@ export interface GetScim2MeConfig extends Omit } /** - * Retrieves the user profile information from the specified SCIM2 /Me endpoint. + * Retrieves the user profile information from the specified /users/me endpoint. * This function uses the ThunderID SPA client's httpClient by default, but allows for custom fetchers. * * @param requestConfig - Request configuration object. @@ -50,8 +50,8 @@ export interface GetScim2MeConfig extends Omit * ```typescript * // Using default ThunderID SPA client httpClient * try { - * const userProfile = await getScim2Me({ - * url: "https://localhost:8090/scim2/Me", + * const userProfile = await getUsersMe({ + * url: "https://localhost:8090/users/me", * }); * console.log(userProfile); * } catch (error) { @@ -65,8 +65,8 @@ export interface GetScim2MeConfig extends Omit * ```typescript * // Using custom fetcher * try { - * const userProfile = await getScim2Me({ - * url: "https://localhost:8090/scim2/Me", + * const userProfile = await getUsersMe({ + * url: "https://localhost:8090/users/me", * fetcher: customFetchFunction * }); * console.log(userProfile); @@ -77,7 +77,7 @@ export interface GetScim2MeConfig extends Omit * } * ``` */ -const getScim2Me = async ({fetcher, instanceId = 0, ...requestConfig}: GetScim2MeConfig): Promise => { +const getUsersMe = async ({fetcher, instanceId = 0, ...requestConfig}: GetUsersMeConfig): Promise => { const defaultFetcher = async (url: string, config: RequestInit): Promise => { const httpClient: FetchHttpClient = FetchHttpClient.getInstance(instanceId); const response: HttpResponse = await httpClient.request({ @@ -95,10 +95,10 @@ const getScim2Me = async ({fetcher, instanceId = 0, ...requestConfig}: GetScim2M } as Response; }; - return baseGetScim2Me({ + return baseGetUsersMe({ ...requestConfig, fetcher: fetcher || defaultFetcher, }); }; -export default getScim2Me; +export default getUsersMe; diff --git a/packages/react/src/api/updateMeProfile.ts b/packages/react/src/api/updateMeProfile.ts index 0ce84b1..d6b0601 100644 --- a/packages/react/src/api/updateMeProfile.ts +++ b/packages/react/src/api/updateMeProfile.ts @@ -41,7 +41,7 @@ export interface UpdateMeProfileConfig extends Omit = await httpClient.request({ data: config.body ? JSON.parse(config.body as string) : undefined, headers: config.headers as Record, - method: config.method || 'PATCH', + method: config.method || 'PUT', url, } as HttpRequestConfig); diff --git a/packages/react/src/components/auth/Callback/__tests__/Callback.test.tsx b/packages/react/src/components/auth/Callback/__tests__/Callback.test.tsx index d10e7a1..d4ab799 100644 --- a/packages/react/src/components/auth/Callback/__tests__/Callback.test.tsx +++ b/packages/react/src/components/auth/Callback/__tests__/Callback.test.tsx @@ -15,7 +15,7 @@ * under the License. */ -import {render, screen} from '@testing-library/react'; +import {render, screen, cleanup} from '@testing-library/react'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {Callback} from '../Callback'; @@ -33,6 +33,7 @@ describe('Callback', () => { }); afterEach(() => { + cleanup(); window.history.replaceState({}, '', '/'); }); diff --git a/packages/react/src/components/auth/Callback/__tests__/TokenCallback.test.tsx b/packages/react/src/components/auth/Callback/__tests__/TokenCallback.test.tsx index e5ee40f..b7d957c 100644 --- a/packages/react/src/components/auth/Callback/__tests__/TokenCallback.test.tsx +++ b/packages/react/src/components/auth/Callback/__tests__/TokenCallback.test.tsx @@ -15,9 +15,10 @@ * under the License. */ -import {render, waitFor} from '@testing-library/react'; +import {render, waitFor, cleanup} from '@testing-library/react'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {TokenCallback} from '../TokenCallback'; +import ThunderIDContext from '../../../../contexts/ThunderID/ThunderIDContext'; const mockSignIn: any = vi.fn(); const mockSignUp: any = vi.fn(); @@ -43,10 +44,6 @@ const thunderIDContext: any = { signUp: mockSignUp, }; -vi.mock('../../../contexts/ThunderID/useThunderID', () => ({ - default: () => thunderIDContext, -})); - describe('TokenCallback', () => { beforeEach(() => { vi.clearAllMocks(); @@ -55,6 +52,7 @@ describe('TokenCallback', () => { }); afterEach(() => { + cleanup(); sessionStorage.clear(); window.history.replaceState({}, '', '/'); }); @@ -72,7 +70,11 @@ describe('TokenCallback', () => { '/callback?id=exec-1&applicationId=app-1&token=secret-token&type=AUTHENTICATION', ); - render(); + render( + + + , + ); await waitFor(() => { expect(mockSignIn).toHaveBeenCalledWith({ @@ -98,7 +100,11 @@ describe('TokenCallback', () => { }); window.history.replaceState({}, '', '/callback?id=exec-1&applicationId=app-1&token=secret-token&type=REGISTRATION'); - render(); + render( + + + , + ); await waitFor(() => { expect(mockSignUp).toHaveBeenCalledWith({ @@ -120,7 +126,11 @@ describe('TokenCallback', () => { const onNavigate: any = vi.fn(); window.history.replaceState({}, '', '/callback?id=exec-1'); - render(); + render( + + + , + ); await waitFor(() => { expect(onError).toHaveBeenCalledWith( @@ -140,7 +150,11 @@ describe('TokenCallback', () => { mockSignIn.mockRejectedValue(new Error('Invalid token')); window.history.replaceState({}, '', '/callback?id=exec-1&token=secret-token'); - render(); + render( + + + , + ); await waitFor(() => { expect(onError).toHaveBeenCalledWith(expect.objectContaining({message: 'Invalid token'})); diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index eadfa98..b74d57b 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -17,8 +17,8 @@ */ import {cx} from '@emotion/css'; -import {User, withVendorCSSClassPrefix, WellKnownSchemaIds, bem, Preferences} from '@thunderid/browser'; -import {FC, ReactElement, useState, useCallback} from 'react'; +import {User, withVendorCSSClassPrefix, bem, Preferences} from '@thunderid/browser'; +import {FC, ReactElement, useState, useCallback, useEffect} from 'react'; import useStyles from './BaseUserProfile.styles'; import useTheme from '../../../contexts/Theme/useTheme'; import useTranslation from '../../../hooks/useTranslation'; @@ -85,7 +85,6 @@ export interface BaseUserProfileProps { */ preferences?: Preferences; profile?: User; - schemas?: Schema[]; showFields?: string[]; title?: string; @@ -121,7 +120,6 @@ const BaseUserProfile: FC = ({ className = '', cardLayout = true, profile, - schemas = [], flattenedProfile, mode = 'inline', title, @@ -142,6 +140,23 @@ const BaseUserProfile: FC = ({ const [editingFields, setEditingFields] = useState>({}); const {t} = useTranslation(preferences?.i18n); + useEffect(() => { + const nextUser = flattenedProfile ?? profile; + if (!nextUser) return; + + setEditedUser((prev: any) => { + if (!prev) return nextUser; + + const updated = {...prev}; + Object.keys(nextUser).forEach((key) => { + if (!editingFields[key]) { + updated[key] = nextUser[key]; + } + }); + return updated; + }); + }, [flattenedProfile, profile, editingFields]); + /** * Determines if a field should be visible based on showFields, hideFields, and fieldsToSkip arrays. * Priority order: @@ -280,24 +295,7 @@ const BaseUserProfile: FC = ({ } let payload: Record = {}; - - // SCIM Patch Operation Logic: - // - Fields from core schema (urn:ietf:params:scim:schemas:core:2.0:User) - // should be sent directly: {"name":{"givenName":"John"}} - // - Fields from extension schemas (like urn:scim:wso2:schema) - // should be nested under the schema namespace: {"urn:scim:wso2:schema":{"country":"Sri Lanka"}} - if (schema.schemaId && schema.schemaId !== WellKnownSchemaIds.User) { - // For non-core schemas, nest the field under the schema namespace - payload = { - [schema.schemaId]: { - [fieldName]: fieldValue, - }, - }; - } else { - // For core schema or fields without schemaId, use the field path directly - // This handles complex paths like "name.givenName" correctly - set(payload, fieldName, fieldValue); - } + set(payload, fieldName, fieldValue); onUpdate(payload); @@ -659,17 +657,16 @@ const BaseUserProfile: FC = ({ )} - {profileEntries.map(([key, value]: any, index: any) => ( -
-
-
{formatLabel(key)}
-
- {typeof value === 'object' ? : String(value)} -
+ {profileEntries.map(([key, value]: any) => { + const schema: Schema = { name: key, mutability: 'READ_WRITE' }; + const schemaWithValue: any = { ...schema, value }; + + return ( +
+ {renderUserInfo(schemaWithValue)}
- {index < profileEntries.length - 1 && } -
- ))} + ); + })} ); }; @@ -685,49 +682,8 @@ const BaseUserProfile: FC = ({ {error} )} - {schemas && schemas.length > 0 && ( -
- -
- )}
- {schemas && schemas.length > 0 - ? schemas - .filter((schema: any) => { - if (!schema.name || !shouldShowField(schema.name)) return false; - - if (!editable) { - const value: any = flattenedProfile && schema.name ? flattenedProfile[schema.name] : undefined; - return value !== undefined && value !== '' && value !== null; - } - - return true; - }) - .sort((a: any, b: any) => { - const orderA: any = a.displayOrder ? parseInt(a.displayOrder, 10) : 999; - const orderB: any = b.displayOrder ? parseInt(b.displayOrder, 10) : 999; - return orderA - orderB; - }) - .map((schema: any, index: any) => { - const value: any = flattenedProfile && schema.name ? flattenedProfile[schema.name] : undefined; - const schemaWithValue: any = { - ...schema, - value, - }; - - return ( -
- {renderUserInfo(schemaWithValue)} -
- ); - }) - : renderProfileWithoutSchemas()} + {renderProfileWithoutSchemas()}
); diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx index ad91f02..88af2c8 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx @@ -16,11 +16,12 @@ * under the License. */ -import {ThunderIDError, User} from '@thunderid/browser'; +import {ThunderIDError, User, deepMerge} from '@thunderid/browser'; import {FC, ReactElement, useState} from 'react'; // eslint-disable-next-line import/no-named-as-default import BaseUserProfile, {BaseUserProfileProps} from './BaseUserProfile'; import updateMeProfile from '../../../api/updateMeProfile'; +import getUsersMe from '../../../api/getUsersMe'; import useThunderID from '../../../contexts/ThunderID/useThunderID'; import useUser from '../../../contexts/User/useUser'; import useTranslation from '../../../hooks/useTranslation'; @@ -29,7 +30,7 @@ import useTranslation from '../../../hooks/useTranslation'; * Props for the UserProfile component. * Extends BaseUserProfileProps but makes the user prop optional since it will be obtained from useThunderID */ -export type UserProfileProps = Omit; +export type UserProfileProps = Omit; /** * UserProfile component displays the authenticated user's profile information in a @@ -64,18 +65,38 @@ export type UserProfileProps = Omit * ``` */ -const UserProfile: FC = ({preferences, ...rest}: UserProfileProps): ReactElement => { - const {baseUrl, instanceId} = useThunderID(); - const {profile, flattenedProfile, schemas, onUpdateProfile} = useUser(); - const {t} = useTranslation(preferences?.i18n); +const UserProfile: FC = ({preferences, editable = true, ...rest}: UserProfileProps): ReactElement => { + const {baseUrl, instanceId, preferences: contextPreferences} = useThunderID(); + const {profile, flattenedProfile, onUpdateProfile} = useUser(); + const resolvedPreferences = { + ...contextPreferences, + ...preferences, + user: { + ...contextPreferences?.user, + ...preferences?.user, + }, + }; + const {t} = useTranslation(resolvedPreferences?.i18n); + const isEditableProfile: boolean = resolvedPreferences?.user?.fetchUserProfile === false ? false : editable; const [error, setError] = useState(null); - const handleProfileUpdate = async (payload: any): Promise => { + const handleProfileUpdate = async (payload: Record): Promise => { setError(null); try { - const response: User = await updateMeProfile({baseUrl, instanceId, payload}); + const updatedAttributes: Record = deepMerge( + (profile?.['attributes'] as Record) ?? {}, + payload, + ); + + Object.keys(updatedAttributes).forEach((key) => { + if (updatedAttributes[key] === undefined || updatedAttributes[key] === null) { + delete updatedAttributes[key]; + } + }); + + const response: User = await updateMeProfile({baseUrl, instanceId, payload: updatedAttributes}); onUpdateProfile(response); } catch (caughtError: unknown) { let message: string = t('user.profile.update.generic.error'); @@ -92,10 +113,10 @@ const UserProfile: FC = ({preferences, ...rest}: UserProfilePr ); diff --git a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts index 0507021..92f645f 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts +++ b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts @@ -38,6 +38,7 @@ export type ThunderIDContextProps = { applicationId: string | undefined; baseUrl: string | undefined; clientId: string | undefined; + preferences?: ThunderIDReactConfig['preferences']; scopes: string | string[] | undefined; /** * OIDC discovery data. @@ -219,6 +220,7 @@ const ThunderIDContext: Context = createContext {}, clientId: undefined, + preferences: undefined, scopes: undefined, discovery: { wellKnown: null, diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index c69eed8..46b0d7a 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -39,6 +39,7 @@ import FlowMetaProvider from '../FlowMeta/FlowMetaProvider'; import I18nProvider from '../I18n/I18nProvider'; import ThemeProvider from '../Theme/ThemeProvider'; import UserProvider from '../User/UserProvider'; +import getUsersMe from '../../api/getUsersMe'; const logger: ReturnType = createPackageComponentLogger( '@thunderid/react', @@ -130,18 +131,28 @@ const ThunderIDProvider: FC> = ({ setBaseUrl(resolvedBaseUrl); } - // TEMPORARY: SCIM2 and Organizations endpoints are not yet supported. + const shouldFetchProfile: boolean = preferences?.user?.fetchUserProfile !== false; const claims: User = extractUserClaimsFromIdToken(decodedToken) as User; - setUser(claims); + let profileData = claims; + const currentSignInStatus: boolean = await client.isSignedIn(); + + if (currentSignInStatus && shouldFetchProfile) { + try { + const fetchedProfile = await getUsersMe({baseUrl: resolvedBaseUrl, instanceId}); + profileData = {...claims, ...fetchedProfile}; + } catch (err) { + logger.warn('Failed to fetch user profile from /users/me:', err); + } + } + + setUser(profileData); setUserProfile({ - flattenedProfile: claims, - profile: claims, - schemas: [], + flattenedProfile: generateFlattenedUserProfile(profileData, []), + profile: profileData, }); // CRITICAL: Update sign-in status BEFORE setting loading to false // This prevents the race condition where ProtectedRoute sees isLoading=false but isSignedIn=false - const currentSignInStatus: boolean = await client.isSignedIn(); setIsSignedInSync(currentSignInStatus); } catch (error) { // TODO: Add an error log. @@ -199,7 +210,7 @@ const ThunderIDProvider: FC> = ({ (async (): Promise => { // Sync session state whenever sign-in completes (both redirect and embedded V2 flows). - // Pass the user returned by the SDK's sign-in flow so SCIM2/Me result is not discarded. + // Pass the user returned by the SDK's sign-in flow so users/me result is not discarded. await client.on('sign-in', async () => { await updateSession(); }); @@ -364,8 +375,7 @@ const ThunderIDProvider: FC> = ({ const handleProfileUpdate = (payload: User): void => { setUser(payload); setUserProfile((prev: UserProfile | null) => ({ - schemas: prev?.schemas ?? [], - flattenedProfile: generateFlattenedUserProfile(payload, prev?.schemas ?? []), + flattenedProfile: generateFlattenedUserProfile(payload), profile: payload, })); }; @@ -456,6 +466,7 @@ const ThunderIDProvider: FC> = ({ isSignedIn: isSignedInSync, organizationChain, organizationHandle: config?.organizationHandle, + preferences, reInitialize, recover, signIn, @@ -496,6 +507,7 @@ const ThunderIDProvider: FC> = ({ getStorageManager, instanceId, organizationChain, + preferences, recover, reInitialize, request, diff --git a/packages/react/src/contexts/User/UserContext.ts b/packages/react/src/contexts/User/UserContext.ts index 8d448a1..ee0b60d 100644 --- a/packages/react/src/contexts/User/UserContext.ts +++ b/packages/react/src/contexts/User/UserContext.ts @@ -16,7 +16,7 @@ * under the License. */ -import {User, Schema, UpdateMeProfileConfig} from '@thunderid/browser'; +import {User, UpdateMeProfileConfig} from '@thunderid/browser'; import {Context, createContext} from 'react'; /** @@ -27,7 +27,6 @@ export interface UserContextProps { onUpdateProfile: (payload: User) => void; profile: User | null; revalidateProfile: () => Promise; - schemas: Schema[] | null; updateProfile: ( requestConfig: UpdateMeProfileConfig, sessionId?: string, @@ -42,7 +41,6 @@ const UserContext: Context = createContext null, profile: null, revalidateProfile: () => null as unknown as Promise, - schemas: null, updateProfile: () => null as unknown as Promise<{data: {user: User}; error: string; success: boolean}>, }); diff --git a/packages/react/src/contexts/User/UserProvider.tsx b/packages/react/src/contexts/User/UserProvider.tsx index f5a87f0..f1de586 100644 --- a/packages/react/src/contexts/User/UserProvider.tsx +++ b/packages/react/src/contexts/User/UserProvider.tsx @@ -38,7 +38,6 @@ export interface UserProviderProps { * * This provider: * - Fetches user profile data from the ME endpoint - * - Retrieves SCIM2 schemas for profile structure * - Generates both nested and flattened user profiles * - Provides functions for refreshing and updating user data * - Handles loading states and errors @@ -74,7 +73,6 @@ const UserProvider: FC> = ({ onUpdateProfile, profile: profile?.profile, revalidateProfile, - schemas: profile?.schemas, updateProfile, }), [profile, onUpdateProfile, revalidateProfile, updateProfile], diff --git a/packages/react/src/contexts/User/useUser.ts b/packages/react/src/contexts/User/useUser.ts index 4d22f04..b7b5847 100644 --- a/packages/react/src/contexts/User/useUser.ts +++ b/packages/react/src/contexts/User/useUser.ts @@ -24,7 +24,6 @@ import UserContext, {UserContextProps} from './UserContext'; * * This hook provides access to user profile data including: * - Raw profile API response - * - SCIM2 schemas * - Nested user object * - Flattened user profile * - Functions to refresh and update user data diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 398ed42..16230b1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -241,8 +241,8 @@ export {default as BuildingAlt} from './components/primitives/Icons/BuildingAlt' export {default as updateMeProfile} from './api/updateMeProfile'; export type {UpdateMeProfileConfig} from './api/updateMeProfile'; -export {default as getMeProfile} from './api/getScim2Me'; -export * from './api/getScim2Me'; +export {default as getMeProfile} from './api/getUsersMe'; +export * from './api/getUsersMe'; export { ThunderIDRuntimeError, diff --git a/packages/vue/src/ThunderIDVueClient.ts b/packages/vue/src/ThunderIDVueClient.ts index ca08145..3a50b2d 100644 --- a/packages/vue/src/ThunderIDVueClient.ts +++ b/packages/vue/src/ThunderIDVueClient.ts @@ -18,11 +18,9 @@ import { ThunderIDBrowserClient, - flattenUserSchema, generateFlattenedUserProfile, UserProfile, User, - generateUserProfile, SignUpOptions, ThunderIDRuntimeError, executeEmbeddedSignUpFlow, @@ -39,8 +37,7 @@ import { EmbeddedSignUpFlowStatus, StorageManager, } from '@thunderid/browser'; -import getSchemas from './api/getSchemas'; -import getScim2Me from './api/getScim2Me'; +import getUsersMe from './api/getUsersMe'; import {ThunderIDVueConfig} from './models/config'; /** @@ -107,10 +104,9 @@ class ThunderIDVueClient exte baseUrl = configData?.baseUrl; } - const profile: User = await getScim2Me({baseUrl}); - const schemas: any = await getSchemas({baseUrl}); + const profile: User = await getUsersMe({baseUrl}); - return generateUserProfile(profile, flattenUserSchema(schemas)); + return profile; } catch (error) { return extractUserClaimsFromIdToken(await this.getDecodedIdToken()); } @@ -134,15 +130,11 @@ class ThunderIDVueClient exte baseUrl = configData?.baseUrl; } - const profile: User = await getScim2Me({baseUrl, instanceId: this.getInstanceId()}); - const schemas: any = await getSchemas({baseUrl, instanceId: this.getInstanceId()}); - - const processedSchemas: any = flattenUserSchema(schemas); + const profile: User = await getUsersMe({baseUrl, instanceId: this.getInstanceId()}); const output: UserProfile = { - flattenedProfile: generateFlattenedUserProfile(profile, processedSchemas), + flattenedProfile: generateFlattenedUserProfile(profile), profile, - schemas: processedSchemas, }; return output; @@ -150,7 +142,6 @@ class ThunderIDVueClient exte return { flattenedProfile: extractUserClaimsFromIdToken(await this.getDecodedIdToken()), profile: extractUserClaimsFromIdToken(await this.getDecodedIdToken()), - schemas: [], }; } }); diff --git a/packages/vue/src/api/getSchemas.ts b/packages/vue/src/api/getSchemas.ts deleted file mode 100644 index e236fac..0000000 --- a/packages/vue/src/api/getSchemas.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - Schema, - HttpResponse, - FetchHttpClient, - HttpRequestConfig, - getSchemas as baseGetSchemas, - GetSchemasConfig as BaseGetSchemasConfig, -} from '@thunderid/browser'; - -export interface GetSchemasConfig extends Omit { - fetcher?: (url: string, config: RequestInit) => Promise; - instanceId?: number; -} - -const getSchemas = async ({fetcher, instanceId = 0, ...requestConfig}: GetSchemasConfig): Promise => { - const defaultFetcher = async (url: string, config: RequestInit): Promise => { - const httpClient: FetchHttpClient = FetchHttpClient.getInstance(instanceId); - - const response: HttpResponse = await httpClient.request({ - headers: config.headers as Record, - method: config.method || 'GET', - url, - } as HttpRequestConfig); - - return { - json: () => Promise.resolve(response.data), - ok: response.status >= 200 && response.status < 300, - status: response.status, - statusText: response.statusText || '', - text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)), - } as Response; - }; - - return baseGetSchemas({ - ...requestConfig, - fetcher: fetcher || defaultFetcher, - }); -}; - -export default getSchemas; diff --git a/packages/vue/src/api/getScim2Me.ts b/packages/vue/src/api/getUsersMe.ts similarity index 84% rename from packages/vue/src/api/getScim2Me.ts rename to packages/vue/src/api/getUsersMe.ts index 612a4cf..e608f43 100644 --- a/packages/vue/src/api/getScim2Me.ts +++ b/packages/vue/src/api/getUsersMe.ts @@ -21,16 +21,16 @@ import { HttpResponse, FetchHttpClient, HttpRequestConfig, - getScim2Me as baseGetScim2Me, - GetScim2MeConfig as BaseGetScim2MeConfig, + getUsersMe as baseGetUsersMe, + GetUsersMeConfig as BaseGetUsersMeConfig, } from '@thunderid/browser'; -export interface GetScim2MeConfig extends Omit { +export interface GetUsersMeConfig extends Omit { fetcher?: (url: string, config: RequestInit) => Promise; instanceId?: number; } -const getScim2Me = async ({fetcher, instanceId = 0, ...requestConfig}: GetScim2MeConfig): Promise => { +const getUsersMe = async ({fetcher, instanceId = 0, ...requestConfig}: GetUsersMeConfig): Promise => { const defaultFetcher = async (url: string, config: RequestInit): Promise => { const httpClient: FetchHttpClient = FetchHttpClient.getInstance(instanceId); @@ -49,10 +49,10 @@ const getScim2Me = async ({fetcher, instanceId = 0, ...requestConfig}: GetScim2M } as Response; }; - return baseGetScim2Me({ + return baseGetUsersMe({ ...requestConfig, fetcher: fetcher || defaultFetcher, }); }; -export default getScim2Me; +export default getUsersMe; diff --git a/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts b/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts index 2fa65ee..250a904 100644 --- a/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts +++ b/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts @@ -16,7 +16,7 @@ * under the License. */ -import {type User, type Schema, WellKnownSchemaIds, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {type User, withVendorCSSClassPrefix} from '@thunderid/browser'; import {type Component, type PropType, type Ref, type SetupContext, type VNode, defineComponent, h, ref} from 'vue'; import getDisplayName from '../../../utils/getDisplayName'; import getMappedUserProfileValue from '../../../utils/getMappedUserProfileValue'; @@ -60,7 +60,7 @@ export interface BaseUserProfileProps { isLoading?: boolean; onUpdate?: (payload: any) => Promise; profile?: User | null; - schemas?: Schema[] | null; + schemas?: any[] | null; showAvatar?: boolean; showFields?: string[]; title?: string; @@ -127,45 +127,14 @@ function formatLabel(key: string): string { .join(' '); } -function buildScimPatchValue( +function buildPatchValue( flatKey: string, rawValue: any, - schemaId: string | undefined, + _schemaId: string | undefined, multiValued: boolean | undefined, ): Record { - if (flatKey === 'phoneNumbers.mobile') { - return { - phoneNumbers: [{type: 'mobile', value: rawValue}], - [WellKnownSchemaIds.SystemUser]: {mobileNumbers: [rawValue]}, - }; - } - - const complexMultiValued = new Set([ - 'phoneNumbers', - 'emails', - 'ims', - 'photos', - 'addresses', - 'entitlements', - 'roles', - 'x509Certificates', - ]); - - const dotIndex: number = flatKey.indexOf('.'); - if (dotIndex > 0) { - const head: string = flatKey.slice(0, dotIndex); - const tail: string = flatKey.slice(dotIndex + 1); - if (complexMultiValued.has(head)) { - return {[head]: [{type: tail, value: rawValue}]}; - } - } - const value: unknown = multiValued ? [rawValue] : rawValue; - if (schemaId && schemaId !== WellKnownSchemaIds.User) { - return {[schemaId]: {[flatKey]: value}}; - } - const segments: string[] = flatKey.split('.'); const nested: Record = {}; let cursor: Record = nested; @@ -204,7 +173,7 @@ const BaseUserProfile: Component = defineComponent({ isLoading: {default: false, type: Boolean}, onUpdate: {default: undefined, type: Function as PropType<(payload: any) => Promise>}, profile: {default: null, type: Object as PropType}, - schemas: {default: () => [], type: Array as PropType}, + schemas: {default: () => [], type: Array as PropType}, /** Whether to render the avatar hero banner. */ showAvatar: {default: true, type: Boolean}, showFields: {default: () => [], type: Array as PropType}, @@ -242,7 +211,7 @@ const BaseUserProfile: Component = defineComponent({ function saveField(schema: ExtendedSchema): void { if (!props.onUpdate || !schema.name) return; const value: any = editedValues.value[schema.name] ?? ''; - const payload: Record = buildScimPatchValue( + const payload: Record = buildPatchValue( schema.name, value, schema.schemaId, diff --git a/packages/vue/src/components/presentation/user-profile/UserProfile.ts b/packages/vue/src/components/presentation/user-profile/UserProfile.ts index 7694190..76b4ac0 100644 --- a/packages/vue/src/components/presentation/user-profile/UserProfile.ts +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.ts @@ -69,7 +69,7 @@ const UserProfile: Component = defineComponent({ }, setup(props: UserProfileProps, {slots}: SetupContext): () => VNode { const {baseUrl, instanceId} = useThunderID(); - const {flattenedProfile, profile, schemas, onUpdateProfile} = useUser(); + const {flattenedProfile, profile, onUpdateProfile} = useUser(); const {t} = useI18n(); const error: Ref = ref(null); @@ -109,7 +109,6 @@ const UserProfile: Component = defineComponent({ hideFields: props.hideFields, onUpdate: handleProfileUpdate, profile: profile?.value?.profile ?? flattenedProfile?.value, - schemas: schemas?.value, showAvatar: props.showAvatar, showFields: props.showFields, title: props.title, diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index 838d7a5..7d03f04 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -21,7 +21,6 @@ import type { HttpRequestConfig, HttpResponse, IdToken, - Schema, SignInOptions, StorageManager, Theme, @@ -115,14 +114,12 @@ export interface UserContextValue { flattenedProfile: Readonly>; /** Called after a successful profile update to sync state up to ThunderIDProvider. */ onUpdateProfile: (payload: User) => void; - /** The raw nested user profile from the SCIM2/ME endpoint. */ + /** The raw nested user profile from the users/me endpoint. */ profile: Readonly>; /** Refetch the user profile from the server. */ revalidateProfile: () => Promise; - /** The SCIM2 schemas describing the user profile attributes. */ - schemas: Readonly>; /** - * Update the user profile. Accepts the standard SCIM2 patch request config. + * Update the user profile. Accepts the standard patch request config. */ updateProfile: ( requestConfig: UpdateMeProfileConfig, diff --git a/packages/vue/src/providers/ThunderIDProvider.ts b/packages/vue/src/providers/ThunderIDProvider.ts index 32f265a..9caf070 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -227,7 +227,6 @@ const ThunderIDProvider: Component = defineComponent({ const profileData: UserProfile = { flattenedProfile: claims, profile: claims, - schemas: [], }; userProfile.value = profileData; @@ -461,12 +460,8 @@ const ThunderIDProvider: Component = defineComponent({ onUpdateProfile: (updatedUser: User): void => { user.value = updatedUser; userProfile.value = { - flattenedProfile: generateFlattenedUserProfile( - updatedUser, - userProfile.value?.schemas ?? [], - ), + flattenedProfile: generateFlattenedUserProfile(updatedUser), profile: updatedUser, - schemas: userProfile.value?.schemas ?? [], }; }, profile: userProfile.value, @@ -478,7 +473,6 @@ const ThunderIDProvider: Component = defineComponent({ userProfile.value = { flattenedProfile: claims, profile: claims, - schemas: [], }; } catch { // silent diff --git a/packages/vue/src/providers/UserProvider.ts b/packages/vue/src/providers/UserProvider.ts index bddbf96..97f5924 100644 --- a/packages/vue/src/providers/UserProvider.ts +++ b/packages/vue/src/providers/UserProvider.ts @@ -16,7 +16,7 @@ * under the License. */ -import {Schema, UpdateMeProfileConfig, User, UserProfile} from '@thunderid/browser'; +import {UpdateMeProfileConfig, User, UserProfile} from '@thunderid/browser'; import { computed, defineComponent, @@ -60,7 +60,7 @@ const UserProvider: Component = defineComponent({ profile: {default: null, type: Object as PropType}, /** Re-fetch the user profile from the server. */ revalidateProfile: {default: async () => {}, type: Function as PropType<() => Promise>}, - /** Update the user profile via SCIM2 PATCH. */ + /** Update the user profile via PATCH. */ updateProfile: { default: undefined, type: Function as PropType< @@ -72,18 +72,16 @@ const UserProvider: Component = defineComponent({ }, }, setup(props: UserProviderProps, {slots}: SetupContext): () => VNode { - // Derive flattenedProfile and schemas from the single profile prop, + // Derive flattenedProfile from the single profile prop, // matching the same pattern as the React SDK's UserProvider. const profileRef: Ref = computed(() => props.profile); const flattenedProfileRef: Ref = computed(() => props.profile?.flattenedProfile ?? null); - const schemasRef: Ref = computed(() => props.profile?.schemas ?? null); const context: UserContextValue = { flattenedProfile: flattenedProfileRef as unknown as Readonly>, onUpdateProfile: props.onUpdateProfile ?? ((): void => {}), profile: profileRef as unknown as Readonly>, revalidateProfile: props.revalidateProfile, - schemas: schemasRef as unknown as Readonly>, updateProfile: props.updateProfile ?? (async (): Promise<{data: {user: User}; error: string; success: boolean}> => ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebd0696..fa7eff3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -697,6 +697,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@vitest/browser-playwright': + specifier: 'catalog:' + version: 4.1.8(playwright@1.60.0)(vite@7.3.5(@types/node@24.7.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8) eslint: specifier: 'catalog:' version: 9.39.4(jiti@2.7.0)