From 93d0cbb2050593a713a59dd70062e66d919b7902 Mon Sep 17 00:00:00 2001 From: janithjay Date: Tue, 21 Jul 2026 11:43:13 +0530 Subject: [PATCH] integrate users/me/meta endpoint into the Vue and Nuxt sdks --- packages/nuxt/src/module.ts | 3 + .../src/runtime/components/ThunderIDRoot.ts | 9 +- .../nuxt/src/runtime/plugins/thunderid.ts | 9 +- .../src/runtime/server/ThunderIDNuxtClient.ts | 93 ++++++- .../runtime/server/plugins/thunderid-ssr.ts | 10 +- .../nuxt/src/runtime/server/utils/session.ts | 8 +- packages/nuxt/src/runtime/types.ts | 78 +----- packages/nuxt/src/runtime/utils/stateKeys.ts | 14 +- .../unit/define-thunderid-middleware.test.ts | 6 +- .../nuxt/tests/unit/thunderid-ssr.test.ts | 3 + packages/vue/src/api/getUsersMeMeta.ts | 62 +++++ packages/vue/src/api/updateMeProfile.ts | 2 +- .../user-dropdown/BaseUserDropdown.ts | 161 +++++++++--- .../user-dropdown/UserDropdown.css.ts | 24 +- .../user-profile/BaseUserProfile.ts | 242 ++++++++++++----- .../user-profile/UserProfile.css.ts | 248 +++++++----------- .../presentation/user-profile/UserProfile.ts | 77 +++++- packages/vue/src/index.ts | 2 + packages/vue/src/models/contexts.ts | 16 +- .../vue/src/providers/ThunderIDProvider.ts | 73 ++++-- packages/vue/src/providers/UserProvider.ts | 24 +- 21 files changed, 781 insertions(+), 383 deletions(-) create mode 100644 packages/vue/src/api/getUsersMeMeta.ts diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 69512dbb..ee66fae4 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -88,6 +88,7 @@ export default defineNuxtModule({ applicationId: publicConfig.applicationId, baseUrl: publicConfig.baseUrl, clientId: publicConfig.clientId, + endpoints: publicConfig.endpoints, platform: publicConfig.platform, preferences: publicConfig.preferences, scopes: publicConfig.scopes, @@ -102,6 +103,7 @@ export default defineNuxtModule({ applicationId?: string; baseUrl: string; clientId: string; + endpoints?: ThunderIDNuxtConfig['endpoints']; platform?: ThunderIDNuxtConfig['platform']; preferences: ThunderIDNuxtConfig['preferences']; scopes: string | string[]; @@ -286,6 +288,7 @@ declare module '@nuxt/schema' { applicationId?: string; baseUrl: string; clientId: string; + endpoints?: ThunderIDNuxtConfig['endpoints']; platform?: ThunderIDNuxtConfig['platform']; preferences?: ThunderIDNuxtConfig['preferences']; scopes: string | string[]; diff --git a/packages/nuxt/src/runtime/components/ThunderIDRoot.ts b/packages/nuxt/src/runtime/components/ThunderIDRoot.ts index 11ad3bca..76e728ce 100644 --- a/packages/nuxt/src/runtime/components/ThunderIDRoot.ts +++ b/packages/nuxt/src/runtime/components/ThunderIDRoot.ts @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import {generateFlattenedUserProfile} from '@thunderid/browser'; -import type {UpdateMeProfileConfig, User, UserProfile} from '@thunderid/node'; +import type {AttributeSchema, UpdateMeProfileConfig, User, UserProfile} from '@thunderid/node'; import {FlowMetaProvider, FlowProvider, I18nProvider, ThemeProvider, UserProvider} from '@thunderid/vue'; import {defineComponent, h, type Component, type Ref, type SetupContext, type VNode} from 'vue'; import type {ThunderIDAuthState, ThunderIDNuxtConfig} from '../types'; -import {getAuthStateKey, getUserProfileStateKey} from '../utils/stateKeys'; +import {getAuthStateKey, getUserProfileStateKey, getUserSchemaStateKey} from '../utils/stateKeys'; import {useState, useRuntimeConfig} from '#imports'; /** @@ -55,6 +55,10 @@ const ThunderIDRoot: Component = defineComponent({ // ── Read SSR-hydrated state keys (seeded by the Nuxt plugin) ──────────── const userProfileState: Ref = useState(getUserProfileStateKey(vendor)); + const userSchemaState: Ref | null> = useState | null>(getUserSchemaStateKey(vendor)); // Used by onUpdateProfile to keep the top-level auth user claim in sync. const authState: Ref = useState(getAuthStateKey(vendor)); @@ -176,6 +180,7 @@ const ThunderIDRoot: Component = defineComponent({ profile: shouldFetchProfile ? userProfileState.value : null, revalidateProfile: shouldFetchProfile ? revalidateProfile : undefined, updateProfile: shouldFetchProfile ? updateProfile : undefined, + userSchema: shouldFetchProfile ? userSchemaState.value : null, }, { default: (): VNode | VNode[] | undefined => slots.default?.(), diff --git a/packages/nuxt/src/runtime/plugins/thunderid.ts b/packages/nuxt/src/runtime/plugins/thunderid.ts index 8a7c31c2..100c788e 100644 --- a/packages/nuxt/src/runtime/plugins/thunderid.ts +++ b/packages/nuxt/src/runtime/plugins/thunderid.ts @@ -3,14 +3,14 @@ import {getRedirectBasedSignUpUrl} from '@thunderid/browser'; import {VendorConstants} from '@thunderid/node'; -import type {UserProfile} from '@thunderid/node'; +import type {AttributeSchema, UserProfile} from '@thunderid/node'; import {ThunderIDPlugin, THUNDERID_KEY} from '@thunderid/vue'; import type {H3Event} from 'h3'; import {computed} from 'vue'; import type {ComputedRef, Ref} from 'vue'; import ThunderIDRoot from '../components/ThunderIDRoot'; import type {ThunderIDAuthState, ThunderIDSSRData} from '../types'; -import {getAuthStateKey, getUserProfileStateKey} from '../utils/stateKeys'; +import {getAuthStateKey, getUserProfileStateKey, getUserSchemaStateKey} from '../utils/stateKeys'; import type {NuxtApp} from '#app'; import {defineNuxtPlugin, useState, useRequestEvent, useRuntimeConfig, navigateTo} from '#app'; @@ -88,6 +88,10 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { getUserProfileStateKey(vendor), () => null, ); + const userSchemaState: Ref | null> = useState | null>( + getUserSchemaStateKey(vendor), + () => null, + ); if (import.meta.server) { const event: H3Event | undefined = useRequestEvent(); @@ -101,6 +105,7 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { user: ssr.user, }; userProfileState.value = ssr.userProfile; + userSchemaState.value = ssr.userSchema ?? null; } else { // Backwards-compat: fall back to the legacy context shape (pre-Step-2 plugin). const ssrContext: {isSignedIn?: boolean; session?: {sub?: string}} | undefined = ( diff --git a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts index 57c4ff68..a9847da9 100644 --- a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts +++ b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts @@ -3,6 +3,14 @@ import { ThunderIDNodeClient, + ThunderIDRuntimeError, + extractUserClaimsFromIdToken, + generateFlattenedUserProfile, + getUsersMe, + getUsersMeMeta, + resolveResourceEndpoint, + updateMeProfile, + type AttributeSchema, type AuthClientConfig, type IdToken, type Storage, @@ -44,6 +52,7 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { clientId: config.clientId!, clientSecret: config.clientSecret || undefined, enablePKCE: true, + endpoints: config.endpoints, scopes: config.scopes || ['openid', 'profile'], tokenRequest: config.tokenRequest, } as AuthClientConfig; @@ -124,8 +133,23 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { return (configData?.afterSignOutUrl as string) || (configData?.afterSignInUrl as string) || '/'; } - override getUser(sessionId?: string): Promise { - return super.getUser(sessionId); + override async getUser(sessionId?: string): Promise { + try { + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = configData?.baseUrl; + + const profile: User = await getUsersMe({ + baseUrl, + url: resolveResourceEndpoint('usersMe', configData), + headers: { + Authorization: `Bearer ${await this.getAccessToken(sessionId)}`, + }, + }); + + return profile; + } catch (error) { + return await super.getUser(sessionId); + } } override getAccessToken(sessionId?: string): Promise { @@ -144,13 +168,68 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { return super.exchangeToken(config, sessionId) as unknown as Promise; } - override async getUserProfile(sessionId: string): Promise { - const user: User = await this.getUser(sessionId); - return {flattenedProfile: user, profile: user}; + override async getUserProfile(sessionId?: string): Promise { + try { + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = configData?.baseUrl; + + const profile: User = await getUsersMe({ + baseUrl, + url: resolveResourceEndpoint('usersMe', configData), + headers: { + Authorization: `Bearer ${await this.getAccessToken(sessionId)}`, + }, + }); + + return { + flattenedProfile: generateFlattenedUserProfile(profile), + profile, + }; + } catch (error) { + const claims = extractUserClaimsFromIdToken(await super.getDecodedIdToken(sessionId)); + return { + flattenedProfile: claims, + profile: claims, + }; + } + } + + override async updateUserProfile(config: UpdateMeProfileConfig, sessionId?: string): Promise { + try { + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = configData?.baseUrl; + + return updateMeProfile({ + baseUrl, + url: resolveResourceEndpoint('usersMe', configData), + headers: { + Authorization: `Bearer ${await this.getAccessToken(sessionId)}`, + }, + payload: (config as any)?.payload ?? config, + }); + } catch (error) { + throw new ThunderIDRuntimeError( + `Failed to update user profile: ${error instanceof Error ? error.message : String(error)}`, + 'ThunderIDNuxtClient-UpdateProfileError-001', + 'nuxt', + 'An error occurred while updating the user profile. Please check your configuration and network connection.', + ); + } } - override async updateUserProfile(config: UpdateMeProfileConfig, sessionId: string): Promise { - throw new Error('Profile updates are not supported for the ThunderID platform.'); + async getUserSchema(sessionId?: string): Promise | null> { + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = configData?.baseUrl; + + const metaRes = await getUsersMeMeta({ + baseUrl, + url: resolveResourceEndpoint('usersMeMeta', configData), + headers: { + Authorization: `Bearer ${await this.getAccessToken(sessionId)}`, + }, + }); + + return metaRes?.schema ?? null; } public override getStorageManager(): any { diff --git a/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts b/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts index 20651378..2f511803 100644 --- a/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts +++ b/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts @@ -85,6 +85,7 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { baseUrl: publicConfig.baseUrl, clientId: publicConfig.clientId, clientSecret: privateConfig?.clientSecret || undefined, + endpoints: publicConfig.endpoints, platform: publicConfig.platform, scopes: publicConfig.scopes || ['openid', 'profile'], tokenRequest: publicConfig.tokenRequest, @@ -144,12 +145,15 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { // ── 4. Parallel SSR data fetches (gated by preferences) ─────────────── const shouldFetchProfile: boolean = prefs?.user?.fetchUserProfile !== false; - const [userResult, userProfileResult] = await Promise.allSettled([ + const [userResult, userProfileResult, userSchemaResult] = await Promise.allSettled([ // Always fetch the basic user object (needed for ThunderIDAuthState.user) client.getUser(session.sessionId), // User profile (flattened) shouldFetchProfile ? client.getUserProfile(session.sessionId) : Promise.resolve(null), + + // User schema metadata from /users/me/meta + shouldFetchProfile ? client.getUserSchema(session.sessionId) : Promise.resolve(null), ]); if (userResult.status === 'rejected') { @@ -158,6 +162,9 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { if (userProfileResult.status === 'rejected') { log.warn('Failed to fetch user profile:', userProfileResult.reason); } + if (userSchemaResult.status === 'rejected') { + log.warn('Failed to fetch user schema:', userSchemaResult.reason); + } // ── 5. Write to event context ────────────────────────────────────────── const ssrData: ThunderIDSSRData = { @@ -166,6 +173,7 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { session, user: userResult.status === 'fulfilled' ? userResult.value : null, userProfile: userProfileResult.status === 'fulfilled' ? userProfileResult.value : null, + userSchema: userSchemaResult.status === 'fulfilled' ? userSchemaResult.value : null, }; const eventContext: Record = event.context; diff --git a/packages/nuxt/src/runtime/server/utils/session.ts b/packages/nuxt/src/runtime/server/utils/session.ts index 7ec89a8b..90aa54fb 100644 --- a/packages/nuxt/src/runtime/server/utils/session.ts +++ b/packages/nuxt/src/runtime/server/utils/session.ts @@ -126,15 +126,15 @@ export async function verifyTempSessionToken( /** * Session cookie name. */ -export function getSessionCookieName(): string { - return CookieConfig.SESSION_COOKIE_NAME; +export function getSessionCookieName(vendor?: string): string { + return CookieConfig.getSessionCookieName(vendor); } /** * Temp session cookie name. */ -export function getTempSessionCookieName(): string { - return CookieConfig.TEMP_SESSION_COOKIE_NAME; +export function getTempSessionCookieName(vendor?: string): string { + return CookieConfig.getTempSessionCookieName(vendor); } /** diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index 34a91ff2..cad8d513 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -1,77 +1,25 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {I18nPreferences, TokenEndpointAuthMethod, User, UserProfile} from '@thunderid/node'; +import type { + AttributeSchema, + AuthClientConfig, + I18nPreferences, + TokenEndpointAuthMethod, + User, + UserProfile, +} from '@thunderid/node'; import type {JWTPayload} from 'jose'; /** * Configuration for the ThunderID Nuxt module. + * Extends `AuthClientConfig` from `@thunderid/node` for 1:1 SDK parity. */ -export interface ThunderIDNuxtConfig { - /** URL to redirect to after sign-in (default: '/') */ - afterSignInUrl?: string; - /** URL to redirect to after sign-out (default: '/') */ - afterSignOutUrl?: string; - /** - * ThunderID application id (`spId`) — appended to the redirect-based sign-up - * URL when present. Mirrors `applicationId` in the React/Next.js SDKs. - */ - applicationId?: string; - /** Base URL of the ThunderID org tenant (e.g. https://localhost:8090) */ - baseUrl?: string; - /** OAuth2 Client ID */ - clientId?: string; - /** OAuth2 Client Secret (server-only, use THUNDERID_CLIENT_SECRET env var) */ - clientSecret?: string; - /** - * Feature-gating preferences that control which server-side data fetches - * the Nitro plugin performs on every SSR request. - */ - preferences?: { - /** i18n configuration forwarded to `I18nProvider`. */ - i18n?: I18nPreferences; - theme?: { - /** - * Theme mode forwarded to the Vue SDK's `ThemeProvider`. - * - `'light'` (default) | `'dark'`: Fixed color scheme. Toggle at runtime with `useTheme().toggleTheme()`. - * - `'system'`: Follows the OS `prefers-color-scheme`. - * - `'class'`: Reads a CSS class on `` (works well with Tailwind dark-mode). - * - `'branding'`: Follows the active theme from the tenant's branding preference. - */ - mode?: 'light' | 'dark' | 'system' | 'class' | 'branding'; - }; - user?: { - /** Whether to fetch the user profile during SSR (default: true). */ - fetchUserProfile?: boolean; - }; - }; - /** OAuth2 scopes to request */ - scopes?: string | string[]; +export interface ThunderIDNuxtConfig extends AuthClientConfig { /** Secret for signing session JWTs (use THUNDERID_SESSION_SECRET env var) */ sessionSecret?: string; - /** - * Optional override for the redirect-based sign-in URL. Reserved for - * parity with the React/Next.js SDKs; not currently used by the redirect - * flow (which goes through `/api/auth/signin`). - */ - signInUrl?: string; - /** - * Optional override for the redirect-based sign-up URL. When set, - * `` and `useThunderID().signUp()` (no-arg) navigate - * here instead of deriving the URL from `baseUrl`/`clientId`. - */ - signUpUrl?: string; - /** - * Configuration for the token endpoint request. - */ - tokenRequest?: { - /** - * OAuth 2.0 client authentication method used at the token endpoint. - * Defaults to `client_secret_basic` for ThunderIDV2 and `client_secret_post` - * for all other platforms when not specified. - */ - authMethod?: TokenEndpointAuthMethod; - }; + /** Platform identifier */ + platform?: any; /** * Vendor/brand namespace used to prefix Nuxt `useState` keys, the * `event.context` namespace, and other server-side identifiers. @@ -133,6 +81,8 @@ export interface ThunderIDSSRData { user: User | null; /** Flattened user profile + raw profile (null when `preferences.user.fetchUserProfile` is false). */ userProfile: UserProfile | null; + /** User schema metadata from /users/me/meta (null when `preferences.user.fetchUserProfile` is false). */ + userSchema?: Record | null; } /** diff --git a/packages/nuxt/src/runtime/utils/stateKeys.ts b/packages/nuxt/src/runtime/utils/stateKeys.ts index e5e14047..3a0b92c9 100644 --- a/packages/nuxt/src/runtime/utils/stateKeys.ts +++ b/packages/nuxt/src/runtime/utils/stateKeys.ts @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {VendorConstants} from '@thunderid/node'; +import {getVendorPrefix} from '@thunderid/node'; /** * Shared `useState` key for the ThunderID auth state (`ThunderIDAuthState`). @@ -12,12 +12,18 @@ import {VendorConstants} from '@thunderid/node'; * the same `vendor` (from `useRuntimeConfig().public.thunderid.vendor`) to * read/write the same reactive state. */ -export const getAuthStateKey = (vendor: string = VendorConstants.VENDOR_PREFIX): string => `${vendor}:auth`; +export const getAuthStateKey = (vendor?: string): string => `${getVendorPrefix(vendor)}:auth`; /** * Shared `useState` key for the SSR-hydrated user profile (`UserProfile | null`). * * Must stay in sync across the same three files as {@link getAuthStateKey}. */ -export const getUserProfileStateKey = (vendor: string = VendorConstants.VENDOR_PREFIX): string => - `${vendor}:user-profile`; +export const getUserProfileStateKey = (vendor?: string): string => `${getVendorPrefix(vendor)}:user-profile`; + +/** + * Shared `useState` key for the SSR-hydrated user schema (`Record | null`). + * + * Must stay in sync across the same three files as {@link getAuthStateKey}. + */ +export const getUserSchemaStateKey = (vendor?: string): string => `${getVendorPrefix(vendor)}:user-schema`; diff --git a/packages/nuxt/tests/unit/define-thunderid-middleware.test.ts b/packages/nuxt/tests/unit/define-thunderid-middleware.test.ts index acf9fc50..6574e828 100644 --- a/packages/nuxt/tests/unit/define-thunderid-middleware.test.ts +++ b/packages/nuxt/tests/unit/define-thunderid-middleware.test.ts @@ -34,7 +34,11 @@ vi.mock('#app', () => { // defineNuxtRouteMiddleware just returns the handler unchanged in tests const defineNuxtRouteMiddleware = vi.fn((fn: Function) => fn); - return {navigateTo, useState, defineNuxtRouteMiddleware}; + const useRuntimeConfig = vi.fn(() => ({ + public: {thunderid: {vendor: 'thunderid'}}, + })); + + return {navigateTo, useState, defineNuxtRouteMiddleware, useRuntimeConfig}; }); /** Build a fake `to` route object */ diff --git a/packages/nuxt/tests/unit/thunderid-ssr.test.ts b/packages/nuxt/tests/unit/thunderid-ssr.test.ts index f907fa1c..080bb4ac 100644 --- a/packages/nuxt/tests/unit/thunderid-ssr.test.ts +++ b/packages/nuxt/tests/unit/thunderid-ssr.test.ts @@ -28,6 +28,9 @@ const mockClient = vi.hoisted(() => ({ profile: {sub: 'user-123', email: 'test@example.com'}, flattenedProfile: {email: 'test@example.com'}, }), + getUserSchema: vi.fn<(sessionId: string) => Promise>().mockResolvedValue({ + email: {displayName: 'Email', type: 'string'}, + }), getDecodedIdToken: vi.fn<(sessionId: string) => Promise>().mockResolvedValue({sub: 'user-123'}), })); diff --git a/packages/vue/src/api/getUsersMeMeta.ts b/packages/vue/src/api/getUsersMeMeta.ts new file mode 100644 index 00000000..125560f3 --- /dev/null +++ b/packages/vue/src/api/getUsersMeMeta.ts @@ -0,0 +1,62 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + FetchHttpClient, + HttpRequestConfig, + HttpResponse, + getUsersMeMeta as baseGetUsersMeMeta, + GetUsersMeMetaConfig as BaseGetUsersMeMetaConfig, + UsersMeMetaResponse, + AttributeSchema, +} from '@thunderid/browser'; + +export type {AttributeSchema, UsersMeMetaResponse}; + +/** + * Configuration for the getUsersMeMeta request (Vue-specific) + */ +export interface GetUsersMeMetaConfig extends Omit { + /** + * Optional custom fetcher function. If not provided, the ThunderID SPA client's httpClient will be used + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * Optional instance ID for multi-instance support. Defaults to 0. + */ + instanceId?: number; +} + +/** + * Retrieves the user schema metadata from the specified /users/me/meta endpoint. + * Uses ThunderID SPA client FetchHttpClient by default with multi-instance support. + */ +const getUsersMeMeta = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: GetUsersMeMetaConfig): 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 baseGetUsersMeMeta({ + ...requestConfig, + fetcher: fetcher ?? defaultFetcher, + }); +}; + +export default getUsersMeMeta; diff --git a/packages/vue/src/api/updateMeProfile.ts b/packages/vue/src/api/updateMeProfile.ts index a5cfcc89..a430d584 100644 --- a/packages/vue/src/api/updateMeProfile.ts +++ b/packages/vue/src/api/updateMeProfile.ts @@ -22,7 +22,7 @@ const updateMeProfile = async ({fetcher, instanceId = 0, ...requestConfig}: Upda const response: HttpResponse = 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/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts b/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts index 4d801dbd..d9a92d1c 100644 --- a/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts +++ b/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts @@ -7,11 +7,14 @@ import { type PropType, type Ref, type VNode, + Teleport, defineComponent, h, + nextTick, onMounted, onUnmounted, ref, + watch, } from 'vue'; import getDisplayName from '../../../utils/getDisplayName'; import getMappedUserProfileValue from '../../../utils/getMappedUserProfileValue'; @@ -64,6 +67,7 @@ const DEFAULT_ATTRIBUTE_MAPPINGS: Record = { email: ['emails', 'email'], firstName: ['name.givenName', 'given_name'], lastName: ['name.familyName', 'family_name'], + picture: ['picture', 'avatar', 'pictureUrl', 'attributes.picture'], username: ['userName', 'username', 'user_name'], }; @@ -96,10 +100,11 @@ function resolveUserInfo(user: User | null): { displayName: string; gradient: string; initials: string; + picture: string | null; subtitle: string; } { if (!user) { - return {displayName: 'User', gradient: AVATAR_GRADIENTS[0], initials: '?', subtitle: ''}; + return {displayName: 'User', gradient: AVATAR_GRADIENTS[0], initials: '?', picture: null, subtitle: ''}; } const displayName: string = getDisplayName(DEFAULT_ATTRIBUTE_MAPPINGS, user) || 'User'; @@ -111,6 +116,8 @@ function resolveUserInfo(user: User | null): { .join('') .toUpperCase() || '?'; + const picture: string | null = getMappedUserProfileValue('picture', DEFAULT_ATTRIBUTE_MAPPINGS, user) || null; + const seed = String( getMappedUserProfileValue('username', DEFAULT_ATTRIBUTE_MAPPINGS, user) || getMappedUserProfileValue('email', DEFAULT_ATTRIBUTE_MAPPINGS, user) || @@ -123,7 +130,7 @@ function resolveUserInfo(user: User | null): { '', ); - return {displayName, gradient: getAvatarGradient(seed), initials, subtitle}; + return {displayName, gradient: getAvatarGradient(seed), initials, picture, subtitle}; } // ─── Component ─────────────────────────────────────────────────────────────── @@ -159,8 +166,17 @@ const BaseUserDropdown: Component = defineComponent({ setup(props: BaseUserDropdownProps, {slots}: {slots: any}): () => VNode | VNode[] | null { const isOpen: Ref = ref(false); const containerRef: Ref = ref(null); + const modalOverlayRef: Ref = ref(null); + const avatarImageError: Ref = ref(false); const px: typeof withVendorCSSClassPrefix = withVendorCSSClassPrefix; + watch( + () => resolveUserInfo(props.user ?? null).picture, + (): void => { + avatarImageError.value = false; + }, + ); + // ── Click-outside / Escape ──────────────────────────────────────────────── function handleClickOutside(event: MouseEvent): void { @@ -169,10 +185,52 @@ const BaseUserDropdown: Component = defineComponent({ } } + function handleModalClose(): void { + props.onProfileModalClose?.(); + const triggerBtn = containerRef.value?.querySelector(`.${px('user-dropdown__trigger')}`); + triggerBtn?.focus(); + } + function handleKeyDown(event: KeyboardEvent): void { - if (event.key === 'Escape') isOpen.value = false; + if (props.isProfileModalOpen) { + if (event.key === 'Escape') { + handleModalClose(); + } else if (event.key === 'Tab') { + const overlay = modalOverlayRef.value; + if (overlay) { + const focusables = overlay.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusables.length > 0) { + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + } + } + } else if (event.key === 'Escape') { + isOpen.value = false; + } } + watch( + () => props.isProfileModalOpen, + (open) => { + if (open) { + nextTick(() => { + const closeBtn = modalOverlayRef.value?.querySelector(`.${px('user-dropdown__modal-close')}`); + closeBtn?.focus(); + }); + } + }, + ); + onMounted((): void => { document.addEventListener('click', handleClickOutside); document.addEventListener('keydown', handleKeyDown); @@ -189,9 +247,9 @@ const BaseUserDropdown: Component = defineComponent({ if (props.menuAlign !== 'auto') return props.menuAlign ?? 'right'; if (!containerRef.value) return 'right'; const rect: DOMRect = containerRef.value.getBoundingClientRect(); - const menuWidth: number = MENU_MIN_WIDTHS[props.size ?? 'md'] ?? 220; - // Open toward whichever side has enough room; prefer right. - return window.innerWidth - rect.right >= menuWidth ? 'right' : 'left'; + const spaceRight: number = window.innerWidth - rect.left; + const spaceLeft: number = rect.right; + return spaceRight >= spaceLeft ? 'left' : 'right'; } // ── Render ──────────────────────────────────────────────────────────────── @@ -207,7 +265,7 @@ const BaseUserDropdown: Component = defineComponent({ }); } - const {displayName, initials, gradient, subtitle} = resolveUserInfo(props.user ?? null); + const {displayName, initials, gradient, picture, subtitle} = resolveUserInfo(props.user ?? null); const size: 'sm' | 'md' | 'lg' = props.size ?? 'md'; // ── Trigger ──────────────────────────────────────────────────────────── @@ -233,14 +291,23 @@ const BaseUserDropdown: Component = defineComponent({ type: 'button', }, [ - h( - 'span', - { - class: [px('user-dropdown__avatar'), avatarSizeClass].filter(Boolean).join(' '), - style: {background: gradient}, - }, - initials, - ), + picture && !avatarImageError.value + ? h('img', { + alt: displayName, + class: [px('user-dropdown__avatar'), avatarSizeClass].filter(Boolean).join(' '), + onError: (): void => { + avatarImageError.value = true; + }, + src: picture, + }) + : h( + 'span', + { + class: [px('user-dropdown__avatar'), avatarSizeClass].filter(Boolean).join(' '), + style: {background: gradient}, + }, + initials, + ), props.showChevron ? h('span', {class: px('user-dropdown__chevron')}, [h(ChevronDownIcon, {size: 14})]) : null, ], ); @@ -261,7 +328,13 @@ const BaseUserDropdown: Component = defineComponent({ // Header menuChildren.push( h('div', {class: px('user-dropdown__menu-header')}, [ - h('div', {class: px('user-dropdown__menu-header-avatar'), style: {background: gradient}}, initials), + picture + ? h('img', { + alt: displayName, + class: px('user-dropdown__menu-header-avatar'), + src: picture, + }) + : h('div', {class: px('user-dropdown__menu-header-avatar'), style: {background: gradient}}, initials), h('div', {class: px('user-dropdown__menu-header-info')}, [ h('span', {class: px('user-dropdown__menu-header-name')}, displayName), subtitle ? h('span', {class: px('user-dropdown__menu-header-subtitle')}, subtitle) : null, @@ -355,32 +428,38 @@ const BaseUserDropdown: Component = defineComponent({ if (props.isProfileModalOpen) { return h('div', [ container, - h( - 'div', - { - class: px('user-dropdown__modal-overlay'), - onClick: (e: MouseEvent): void => { - if ((e.target as HTMLElement).classList.contains(px('user-dropdown__modal-overlay'))) { - props.onProfileModalClose?.(); - } + h(Teleport, {to: 'body'}, [ + h( + 'div', + { + 'aria-label': 'User profile', + 'aria-modal': 'true', + class: px('user-dropdown__modal-overlay'), + ref: modalOverlayRef, + role: 'dialog', + onClick: (e: MouseEvent): void => { + if ((e.target as HTMLElement).classList.contains(px('user-dropdown__modal-overlay'))) { + handleModalClose(); + } + }, }, - }, - [ - h('div', {class: px('user-dropdown__modal-content')}, [ - h( - 'button', - { - 'aria-label': 'Close profile', - class: px('user-dropdown__modal-close'), - onClick: props.onProfileModalClose, - type: 'button', - }, - [h(XIcon, {size: 18})], - ), - props.profileContent, - ]), - ], - ), + [ + h('div', {class: px('user-dropdown__modal-content')}, [ + h( + 'button', + { + 'aria-label': 'Close profile', + class: px('user-dropdown__modal-close'), + onClick: handleModalClose, + type: 'button', + }, + [h(XIcon, {size: 18})], + ), + props.profileContent, + ]), + ], + ), + ]), ]); } diff --git a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts index fdb67822..0fb7bc03 100644 --- a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts +++ b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts @@ -79,6 +79,8 @@ const USER_DROPDOWN_CSS = ` justify-content: center; width: 32px; height: 32px; + max-width: 32px; + max-height: 32px; border-radius: 50%; color: #ffffff; flex-shrink: 0; @@ -87,12 +89,20 @@ const USER_DROPDOWN_CSS = ` line-height: 1; user-select: none; pointer-events: none; + object-fit: cover; +} + +img.thunderid-user-dropdown__avatar { + object-fit: cover; + border-radius: 50%; } /* sm — 28 px */ .thunderid-user-dropdown__avatar--sm { width: 28px; height: 28px; + max-width: 28px; + max-height: 28px; font-size: var(--thunder-typography-fontSize-xs); } @@ -100,6 +110,8 @@ const USER_DROPDOWN_CSS = ` .thunderid-user-dropdown__avatar--lg { width: 38px; height: 38px; + max-width: 38px; + max-height: 38px; font-size: var(--thunder-typography-fontSize-md); } @@ -219,6 +231,12 @@ const USER_DROPDOWN_CSS = ` font-weight: var(--thunder-typography-fontWeight-semibold); line-height: 1; user-select: none; + object-fit: cover; +} + +img.thunderid-user-dropdown__menu-header-avatar { + object-fit: cover; + border-radius: 50%; } .thunderid-user-dropdown__menu-header-info { @@ -305,6 +323,7 @@ const USER_DROPDOWN_CSS = ` justify-content: center; z-index: 9999; backdrop-filter: blur(3px); + box-sizing: border-box; animation: thunderid-overlay-enter var(--thunder-transition-fast) ease; } @@ -319,11 +338,14 @@ const USER_DROPDOWN_CSS = ` background: var(--thunder-color-background-surface); border-radius: var(--thunder-border-radius-large); box-shadow: var(--thunder-shadow-large); - max-width: 480px; + max-width: 640px; width: 92%; max-height: 90vh; overflow-y: auto; position: relative; + margin: auto; + padding: calc(var(--thunder-spacing-unit) * 3); + box-sizing: border-box; animation: thunderid-modal-enter var(--thunder-transition-normal) ease; } diff --git a/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts b/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts index 705acc63..34945f08 100644 --- a/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts +++ b/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {type User, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {AttributeSchema, Preferences, type User, startCase, 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'; @@ -25,6 +25,7 @@ interface ExtendedSchema { multiValued?: boolean; mutability?: string; name?: string; + regex?: string; required?: boolean; schemaId?: string; subAttributes?: ExtendedSchema[]; @@ -44,11 +45,14 @@ export interface BaseUserProfileProps { hideFields?: string[]; isLoading?: boolean; onUpdate?: (payload: any) => Promise; + preferences?: Preferences; profile?: User | null; schemas?: any[] | null; showAvatar?: boolean; showFields?: string[]; + t?: (key: string, fallback?: string) => string; title?: string; + userSchema?: Record | null; } // ─── Constants ─────────────────────────────────────────────────────────────── @@ -71,15 +75,28 @@ const FIELDS_TO_SKIP: string[] = [ 'phoneNumbers.mobile', 'emailAddresses', 'preferredMFAOption', + 'attributes', + 'isReadOnly', + 'isReadonly', ]; -const READONLY_FIELDS: string[] = ['username', 'userName', 'user_name']; +const READONLY_FIELDS: string[] = [ + 'username', + 'userName', + 'user_name', + 'sub', + 'id', + 'ouId', + 'attributes', + 'isReadOnly', + 'isReadonly', +]; const DEFAULT_ATTRIBUTE_MAPPINGS: Record = { email: ['emails', 'email'], firstName: ['name.givenName', 'given_name'], lastName: ['name.familyName', 'family_name'], - picture: ['profile', 'profileUrl', 'picture', 'URL'], + picture: ['picture', 'avatar', 'pictureUrl', 'attributes.picture'], username: ['userName', 'username', 'user_name'], }; @@ -157,48 +174,111 @@ const BaseUserProfile: Component = defineComponent({ hideFields: {default: () => [], type: Array as PropType}, isLoading: {default: false, type: Boolean}, onUpdate: {default: undefined, type: Function as PropType<(payload: any) => Promise>}, + preferences: {default: undefined, type: Object as PropType}, profile: {default: null, type: Object 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}, + t: {default: undefined, type: Function as PropType<(key: string, fallback?: string) => string>}, title: {default: 'Profile', type: String}, + /** User schema metadata. */ + userSchema: {default: null, type: Object as PropType | null>}, }, setup(props: BaseUserProfileProps, {slots}: SetupContext): () => VNode | VNode[] | null { const editingFields: Ref> = ref({}); const editedValues: Ref> = ref({}); const px: typeof withVendorCSSClassPrefix = withVendorCSSClassPrefix; + const regexCache = new Map(); + + const t = (key: string, fallback?: string): string => { + if (props.t) { + const res = props.t(key); + if (res && res !== key) return res; + } + return fallback ?? key; + }; // ── Visibility ──────────────────────────────────────────────────────────── - function shouldShowField(fieldName: string): boolean { - if (FIELDS_TO_SKIP.includes(fieldName)) return false; + function shouldShowField(fieldName: string, isSchemaBased = false): boolean { + if (!isSchemaBased && FIELDS_TO_SKIP.includes(fieldName)) return false; if (props.hideFields && props.hideFields.length > 0 && props.hideFields.includes(fieldName)) return false; if (props.showFields && props.showFields.length > 0) return props.showFields.includes(fieldName); return true; } - // ── Edit state ──────────────────────────────────────────────────────────── + const fieldErrors: Ref> = ref({}); function startEditing(fieldName: string, currentValue: any): void { editedValues.value = {...editedValues.value, [fieldName]: currentValue ?? ''}; editingFields.value = {...editingFields.value, [fieldName]: true}; + fieldErrors.value = {...fieldErrors.value, [fieldName]: ''}; } function cancelEditing(fieldName: string): void { const data: User | null = props.flattenedProfile ?? props.profile ?? null; - const originalValue: any = (data as Record)?.[fieldName] ?? ''; + const originalValue: any = + (data as Record)?.[fieldName] ?? (data as any)?.attributes?.[fieldName] ?? ''; editedValues.value = {...editedValues.value, [fieldName]: originalValue}; editingFields.value = {...editingFields.value, [fieldName]: false}; + fieldErrors.value = {...fieldErrors.value, [fieldName]: ''}; } - function saveField(schema: ExtendedSchema): void { + async function saveField(schema: ExtendedSchema): Promise { if (!props.onUpdate || !schema.name) return; - const value: any = editedValues.value[schema.name] ?? ''; - const payload: Record = buildPatchValue(schema.name, value, schema.schemaId, schema.multiValued); - props.onUpdate(payload); - editingFields.value = {...editingFields.value, [schema.name]: false}; + const fieldName: string = schema.name; + const value: any = editedValues.value[fieldName] ?? ''; + const strVal: string = String(value ?? '').trim(); + const fieldLabel: string = schema.displayName || formatLabel(fieldName); + + if (schema.required && !strVal) { + fieldErrors.value = { + ...fieldErrors.value, + [fieldName]: t('userProfile.field.required', `${fieldLabel} is required.`), + }; + return; + } + + if (schema.regex && strVal) { + if (schema.regex.length > 250) { + console.warn(`Regex pattern for field "${fieldName}" exceeds maximum length limit (250 chars).`); + } else { + let reg: RegExp | null | undefined = regexCache.get(schema.regex); + if (reg === undefined) { + try { + reg = new RegExp(schema.regex); + regexCache.set(schema.regex, reg); + } catch (err) { + regexCache.set(schema.regex, null); + console.warn(`Invalid regular expression syntax in user schema for field "${fieldName}":`, err); + } + } + if (reg && !reg.test(strVal)) { + fieldErrors.value = { + ...fieldErrors.value, + [fieldName]: t('userProfile.field.invalidFormat', `${fieldLabel} has an invalid format.`), + }; + return; + } + } + } + + fieldErrors.value = {...fieldErrors.value, [fieldName]: ''}; + const submitVal: any = typeof value === 'string' ? strVal : value; + const payload: Record = buildPatchValue( + fieldName, + submitVal, + schema.schemaId, + schema.multiValued, + ); + try { + await props.onUpdate(payload); + editingFields.value = {...editingFields.value, [fieldName]: false}; + } catch { + // Keep field in editing mode on error so user input is preserved + } } // ── Input rendering per schema type ─────────────────────────────────────── @@ -261,46 +341,59 @@ const BaseUserProfile: Component = defineComponent({ ) : null; const displayValueNode: VNode | null = hasValue - ? h(Typography, {class: px('user-profile__field-value'), variant: 'body1'}, () => String(value)) + ? h('span', {class: px('user-profile__field-value')}, String(value)) : editablePlaceholder; return h('div', {class: px('user-profile__field'), key: name}, [ - h('div', {class: px('user-profile__field-label-col')}, [ - h(Typography, {class: px('user-profile__field-label'), variant: 'body2'}, () => label), - ]), - h('div', {class: px('user-profile__field-value-col')}, [ + h('div', {class: px('user-profile__field-inner')}, [ + h('span', {class: px('user-profile__field-label')}, label), isEditing ? h('div', {class: px('user-profile__field-edit')}, [ renderInput(schema), - h('div', {class: px('user-profile__field-edit-actions')}, [ - h( - Button, - {onClick: () => saveField(schema), size: 'small' as const, variant: 'solid' as const}, - () => 'Save', - ), - h( - Button, - {onClick: () => cancelEditing(name), size: 'small' as const, variant: 'text' as const}, - () => 'Cancel', - ), - ]), + fieldErrors.value[name] + ? h('div', {class: px('user-profile__field-error')}, fieldErrors.value[name]) + : null, ]) - : h('div', {class: px('user-profile__field-display')}, [ - displayValueNode, - isEditable - ? h( - 'button', + : displayValueNode, + ]), + isEditable && !isReadonly + ? h('div', {class: px('user-profile__field-actions')}, [ + isEditing + ? [ + h( + Button, + { + color: 'primary' as const, + onClick: () => saveField(schema), + size: 'small' as const, + variant: 'solid' as const, + }, + () => t('userProfile.actions.save', 'Save'), + ), + h( + Button, { - 'aria-label': `Edit ${label}`, - class: px('user-profile__field-edit-btn'), - onClick: () => startEditing(name, value), - type: 'button', + color: 'secondary' as const, + onClick: () => cancelEditing(name), + size: 'small' as const, + variant: 'solid' as const, }, - [h(PencilIcon)], - ) + () => t('userProfile.actions.cancel', 'Cancel'), + ), + ] + : hasValue + ? h(Button, { + class: px('user-profile__field-edit-btn'), + color: 'secondary' as const, + onClick: () => startEditing(name, value), + size: 'small' as const, + startIcon: h(PencilIcon), + title: t('userProfile.actions.edit', 'Edit'), + variant: 'ghost' as const, + }) : null, - ]), - ]), + ]) + : null, ]); } @@ -318,11 +411,11 @@ const BaseUserProfile: Component = defineComponent({ .sort(([a]: [string, any], [b]: [string, any]) => a.localeCompare(b)) .map(([key, value]: [string, any]) => h('div', {class: px('user-profile__field'), key}, [ - h('div', {class: px('user-profile__field-label-col')}, [ - h(Typography, {class: px('user-profile__field-label'), variant: 'body2'}, () => formatLabel(key)), - ]), - h('div', {class: px('user-profile__field-value-col')}, [ - h(Typography, {class: px('user-profile__field-value'), variant: 'body1'}, () => + h('div', {class: px('user-profile__field-inner')}, [ + h('span', {class: px('user-profile__field-label')}, formatLabel(key)), + h( + 'span', + {class: px('user-profile__field-value')}, typeof value === 'object' ? JSON.stringify(value) : String(value), ), ]), @@ -338,6 +431,9 @@ const BaseUserProfile: Component = defineComponent({ getMappedUserProfileValue('email', DEFAULT_ATTRIBUTE_MAPPINGS, currentUser as User) || getMappedUserProfileValue('username', DEFAULT_ATTRIBUTE_MAPPINGS, currentUser as User); + const picture: string | null = + getMappedUserProfileValue('picture', DEFAULT_ATTRIBUTE_MAPPINGS, currentUser as User) || null; + const avatarSeed = String( currentUser['username'] || currentUser['userName'] || currentUser['email'] || currentUser['sub'] || displayName, ); @@ -354,11 +450,17 @@ const BaseUserProfile: Component = defineComponent({ return h('div', {class: px('user-profile__hero')}, [ h('div', {class: px('user-profile__avatar-wrapper')}, [ - h( - 'div', - {class: [px('user-profile__avatar'), avatarSizeClass].join(' '), style: {background: avatarGradient}}, - [h('span', {class: px('user-profile__avatar-initials')}, initials)], - ), + picture + ? h('img', { + alt: displayName, + class: [px('user-profile__avatar'), avatarSizeClass].join(' '), + src: picture, + }) + : h( + 'div', + {class: [px('user-profile__avatar'), avatarSizeClass].join(' '), style: {background: avatarGradient}}, + [h('span', {class: px('user-profile__avatar-initials')}, initials)], + ), ]), h('div', {class: px('user-profile__hero-info')}, [ h('span', {class: px('user-profile__hero-name')}, displayName), @@ -396,14 +498,6 @@ const BaseUserProfile: Component = defineComponent({ const children: VNode[] = []; - // Title header - children.push( - h('div', {class: px('user-profile__header')}, [ - h('span', {class: px('user-profile__title')}, props.title ?? 'Profile'), - ]), - ); - children.push(h(Divider, {class: px('user-profile__header-divider')})); - // Hero if (props.showAvatar !== false && currentUser) { children.push(renderHero(currentUser)); @@ -417,6 +511,34 @@ const BaseUserProfile: Component = defineComponent({ // Fields if (props.isLoading) { children.push(h('div', {class: px('user-profile__loading')}, [h(Spinner)])); + } else if (props.userSchema && typeof props.userSchema === 'object' && Object.keys(props.userSchema).length > 0) { + const metaSchemas: ExtendedSchema[] = Object.entries(props.userSchema) + .filter(([key, metaAttr]: [string, AttributeSchema]) => { + if (metaAttr?.credential) return false; + return shouldShowField(key, true); + }) + .map(([key, metaAttr]: [string, AttributeSchema]) => { + const val = + editedValues.value?.[key] ?? (currentUser as any)?.attributes?.[key] ?? (currentUser as any)?.[key] ?? ''; + const isReadonly = + metaAttr.readOnly === true || metaAttr.mutability === 'READ_ONLY' || READONLY_FIELDS.includes(key); + + return { + displayName: metaAttr.displayName ?? (key ? startCase(key) : ''), + mutability: isReadonly ? 'READ_ONLY' : 'READ_WRITE', + name: key, + regex: metaAttr.regex, + required: !!metaAttr.required, + type: (metaAttr.type ?? 'STRING').toUpperCase(), + value: val, + }; + }); + + const fieldRows: VNode[] = metaSchemas + .map((schema: ExtendedSchema) => renderSchemaFieldRow(schema)) + .filter((node: VNode | null): node is VNode => node !== null); + + children.push(h('div', {class: px('user-profile__fields')}, fieldRows)); } else if (hasSchemas) { const fieldRows: VNode[] = schemas .filter((s: ExtendedSchema) => s.name && shouldShowField(s.name)) diff --git a/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts b/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts index 6293bb2b..7836c162 100644 --- a/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts @@ -3,51 +3,24 @@ /** * Styles for the UserProfile presentation component. - * - * BEM block: `.thunderid-user-profile` - * - * Modifiers: - * --compact – reduced field padding for modal / dropdown embedding - * - * New elements in this version: - * __hero – avatar + name + subtitle banner - * __avatar--sm/md/lg – avatar size variants - * __hero-name – prominent display name - * __hero-subtitle – secondary line (email / username) + * Parity target: `@thunderid/react` BaseUserProfile.styles.ts */ const USER_PROFILE_CSS = ` /* ============================================================ - UserProfile (modern redesign) + UserProfile (React Parity) ============================================================ */ .thunderid-user-profile { display: flex; flex-direction: column; - min-width: 320px; - overflow: hidden; + padding: calc(var(--thunder-spacing-unit) * 4); + width: 100%; + max-width: 600px; + margin: 0 auto; font-family: var(--thunder-typography-fontFamily); -} - -/* ── Header ─────────────────────────────────────────────────── */ - -.thunderid-user-profile__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: calc(var(--thunder-spacing-unit) * 2) calc(var(--thunder-spacing-unit) * 2.5) - calc(var(--thunder-spacing-unit) * 1.75); -} - -.thunderid-user-profile__title { - margin: 0; - font-size: var(--thunder-typography-fontSize-md); - font-weight: var(--thunder-typography-fontWeight-semibold); - color: var(--thunder-color-text-primary); - letter-spacing: var(--thunder-typography-letterSpacing-tight); -} - -.thunderid-user-profile__header-divider { - margin: 0; + background: var(--thunder-color-background-surface); + border-radius: var(--thunder-border-radius-large, 8px); + box-sizing: border-box; } /* ── Hero (avatar + name + subtitle) ────────────────────────── */ @@ -55,43 +28,35 @@ const USER_PROFILE_CSS = ` .thunderid-user-profile__hero { display: flex; flex-direction: column; - align-items: center; - padding: calc(var(--thunder-spacing-unit) * 3) calc(var(--thunder-spacing-unit) * 2.5) - calc(var(--thunder-spacing-unit) * 2); - gap: calc(var(--thunder-spacing-unit) * 1.25); - background: linear-gradient( - 180deg, - var(--thunder-color-primary-light) 0%, - var(--thunder-color-background-surface) 100% - ); + align-items: flex-start; + gap: calc(var(--thunder-spacing-unit) * 1); + padding-bottom: calc(var(--thunder-spacing-unit) * 2); + margin-bottom: calc(var(--thunder-spacing-unit) * 2); border-bottom: 1px solid var(--thunder-color-border); } .thunderid-user-profile__avatar-wrapper { position: relative; border-radius: 50%; - padding: 3px; - background: linear-gradient( - 135deg, - var(--thunder-color-primary-main), - var(--thunder-color-primary-dark) - ); - box-shadow: 0 4px 14px rgba(75, 110, 245, 0.28); } .thunderid-user-profile__avatar { - width: var(--thunder-avatar-size, 72px); - height: var(--thunder-avatar-size, 72px); + border-radius: 50%; + object-fit: cover; +} + +img.thunderid-user-profile__avatar { + border-radius: 50%; + object-fit: cover; + width: 70px; + height: 70px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; - border: 2px solid var(--thunder-color-background-surface); } -/* Avatar size variants */ - .thunderid-user-profile__avatar--sm { width: 48px; height: 48px; @@ -111,17 +76,17 @@ const USER_PROFILE_CSS = ` } .thunderid-user-profile__avatar--lg { - width: 80px; - height: 80px; + width: 70px; + height: 70px; } .thunderid-user-profile__avatar--lg .thunderid-user-profile__avatar-initials { - font-size: 1.625rem; + font-size: 1.5rem; } .thunderid-user-profile__avatar-initials { color: #ffffff; - font-weight: var(--thunder-typography-fontWeight-semibold); + font-weight: var(--thunder-typography-fontWeight-semibold, 600); line-height: 1; letter-spacing: 0.02em; pointer-events: none; @@ -131,37 +96,36 @@ const USER_PROFILE_CSS = ` .thunderid-user-profile__hero-info { display: flex; flex-direction: column; - align-items: center; - gap: calc(var(--thunder-spacing-unit) * 0.375); - text-align: center; + align-items: flex-start; + margin-top: calc(var(--thunder-spacing-unit) * 0.5); } .thunderid-user-profile__hero-name { - font-size: var(--thunder-typography-fontSize-lg); - font-weight: var(--thunder-typography-fontWeight-semibold); + font-size: var(--thunder-typography-fontSize-xl, 1.5rem); + font-weight: var(--thunder-typography-fontWeight-semibold, 600); color: var(--thunder-color-text-primary); - line-height: var(--thunder-typography-lineHeight-tight); - letter-spacing: var(--thunder-typography-letterSpacing-tight); + margin: 0; + line-height: var(--thunder-typography-lineHeight-tight, 1.2); } .thunderid-user-profile__hero-subtitle { - font-size: var(--thunder-typography-fontSize-sm); + font-size: var(--thunder-typography-fontSize-sm, 0.875rem); color: var(--thunder-color-text-secondary); - line-height: var(--thunder-typography-lineHeight-normal); + margin-top: calc(var(--thunder-spacing-unit) * 0.5); + line-height: var(--thunder-typography-lineHeight-normal, 1.4); } /* ── Alerts & loading ────────────────────────────────────────── */ .thunderid-user-profile__error { - margin: calc(var(--thunder-spacing-unit) * 1.5) calc(var(--thunder-spacing-unit) * 2.5) - calc(var(--thunder-spacing-unit) * 0.5); + margin-bottom: calc(var(--thunder-spacing-unit) * 3); } .thunderid-user-profile__loading { display: flex; align-items: center; justify-content: center; - padding: calc(var(--thunder-spacing-unit) * 3.5) 0; + padding: calc(var(--thunder-spacing-unit) * 4) 0; } /* ── Fields ──────────────────────────────────────────────────── */ @@ -172,141 +136,119 @@ const USER_PROFILE_CSS = ` } .thunderid-user-profile__field { - display: grid; - grid-template-columns: 38% 62%; - align-items: start; - padding: calc(var(--thunder-spacing-unit) * 1.5) calc(var(--thunder-spacing-unit) * 2.5); - gap: calc(var(--thunder-spacing-unit) * 0.75); + display: flex; + align-items: center; + justify-content: space-between; + padding: calc(var(--thunder-spacing-unit) * 1.5) 0; + border-bottom: 1px solid var(--thunder-color-border); + min-height: 28px; box-sizing: border-box; - transition: background-color var(--thunder-transition-fast); } -.thunderid-user-profile__field:hover { - background-color: var(--thunder-color-action-hover); +.thunderid-user-profile__field:last-child { + border-bottom: none; } -.thunderid-user-profile__field + .thunderid-user-profile__field { - border-top: 1px solid var(--thunder-color-border); +.thunderid-user-profile__field-inner { + flex: 1; + display: flex; + align-items: center; + gap: var(--thunder-spacing-unit); } .thunderid-user-profile__field-label { + font-size: var(--thunder-typography-fontSize-sm, 0.875rem); + font-weight: var(--thunder-typography-fontWeight-medium, 500); color: var(--thunder-color-text-secondary); - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-medium); - padding-top: 2px; -} - -.thunderid-user-profile__field-display { - display: flex; - align-items: center; - justify-content: space-between; - gap: calc(var(--thunder-spacing-unit) * 0.5); - min-height: 1.5rem; + width: 120px; + flex-shrink: 0; + line-height: 28px; + text-align: start; } .thunderid-user-profile__field-value { color: var(--thunder-color-text-primary); - word-break: break-word; flex: 1; - font-size: var(--thunder-typography-fontSize-sm); + display: inline-block; + align-items: center; + font-size: var(--thunder-typography-fontSize-sm, 0.875rem); + line-height: 28px; + word-break: break-word; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 350px; + text-align: start; + overflow: hidden; } .thunderid-user-profile__field-placeholder { - color: var(--thunder-color-primary-main); - font-size: var(--thunder-typography-fontSize-sm); + font-size: var(--thunder-typography-fontSize-sm, 0.875rem); font-style: italic; - flex: 1; + color: var(--thunder-color-text-secondary); + opacity: 0.7; cursor: pointer; text-decoration: underline; - text-decoration-style: dotted; - text-underline-offset: 2px; - opacity: 0.8; - transition: opacity var(--thunder-transition-fast); + white-space: nowrap; + line-height: 28px; } .thunderid-user-profile__field-placeholder:hover { opacity: 1; } -/* ── Edit button (pencil) ────────────────────────────────────── */ +.thunderid-user-profile__field-actions { + display: flex; + gap: calc(var(--thunder-spacing-unit) * 0.5); + align-items: center; + margin-inline-start: calc(var(--thunder-spacing-unit) * 4); +} .thunderid-user-profile__field-edit-btn { display: inline-flex; align-items: center; justify-content: center; - background: none; + background: transparent; border: none; cursor: pointer; color: var(--thunder-color-text-secondary); - flex-shrink: 0; - padding: calc(var(--thunder-spacing-unit) * 0.375); - border-radius: var(--thunder-border-radius-small); - transition: - color var(--thunder-transition-fast), - background-color var(--thunder-transition-fast), - opacity var(--thunder-transition-fast); - opacity: 0; + padding: 0; + min-height: auto; + opacity: 0.7; line-height: 0; -} - -.thunderid-user-profile__field:hover .thunderid-user-profile__field-edit-btn { - opacity: 1; + transition: opacity var(--thunder-transition-fast, 0.15s ease); } .thunderid-user-profile__field-edit-btn:hover { - color: var(--thunder-color-primary-main); - background-color: var(--thunder-color-primary-light); -} - -.thunderid-user-profile__field-edit-btn:focus-visible { opacity: 1; - outline: none; - box-shadow: 0 0 0 var(--thunder-focus-ring-width) var(--thunder-focus-ring-color); + background: transparent; } -/* ── Edit mode ───────────────────────────────────────────────── */ - .thunderid-user-profile__field-edit { + flex: 1; display: flex; flex-direction: column; - gap: calc(var(--thunder-spacing-unit) * 0.75); - padding: calc(var(--thunder-spacing-unit) * 0.25) 0; + gap: calc(var(--thunder-spacing-unit) * 0.5); } -.thunderid-user-profile__field-edit-actions { - display: flex; - align-items: center; - gap: calc(var(--thunder-spacing-unit) * 0.75); +.thunderid-user-profile__field-error { + color: var(--thunder-color-error, #d32f2f); + font-size: var(--thunder-typography-fontSize-xs, 0.8rem); + font-weight: var(--thunder-typography-fontWeight-medium, 500); + margin-top: calc(var(--thunder-spacing-unit) * 0.5); } -/* ── Footer slot ─────────────────────────────────────────────── */ +/* ── Footer ──────────────────────────────────────────────────── */ .thunderid-user-profile__footer { - padding: calc(var(--thunder-spacing-unit) * 1.5) calc(var(--thunder-spacing-unit) * 2.5); + padding-top: calc(var(--thunder-spacing-unit) * 2); border-top: 1px solid var(--thunder-color-border); } /* ── Compact modifier ────────────────────────────────────────── */ -.thunderid-user-profile--compact .thunderid-user-profile__hero { - padding: calc(var(--thunder-spacing-unit) * 2) calc(var(--thunder-spacing-unit) * 2); -} - -.thunderid-user-profile--compact .thunderid-user-profile__avatar--lg { - width: 56px; - height: 56px; -} - -.thunderid-user-profile--compact .thunderid-user-profile__avatar--lg .thunderid-user-profile__avatar-initials { - font-size: 1.125rem; -} - -.thunderid-user-profile--compact .thunderid-user-profile__field { - padding: calc(var(--thunder-spacing-unit) * 1) calc(var(--thunder-spacing-unit) * 2); -} - -.thunderid-user-profile--compact .thunderid-user-profile__hero-name { - font-size: var(--thunder-typography-fontSize-md); +.thunderid-user-profile--compact { + padding: calc(var(--thunder-spacing-unit) * 2); + width: 100%; } `; diff --git a/packages/vue/src/components/presentation/user-profile/UserProfile.ts b/packages/vue/src/components/presentation/user-profile/UserProfile.ts index 2881e9ce..d0511115 100644 --- a/packages/vue/src/components/presentation/user-profile/UserProfile.ts +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.ts @@ -1,8 +1,25 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ThunderIDError, User, resolveResourceEndpoint, withVendorCSSClassPrefix} from '@thunderid/browser'; -import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h, ref, type Ref} from 'vue'; +import { + Preferences, + ThunderIDError, + User, + deepMerge, + resolveResourceEndpoint, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import { + type Component, + type PropType, + type SetupContext, + type VNode, + computed, + defineComponent, + h, + ref, + type Ref, +} from 'vue'; import BaseUserProfile from './BaseUserProfile'; import updateMeProfile from '../../../api/updateMeProfile'; import useI18n from '../../../composables/useI18n'; @@ -17,6 +34,7 @@ type UserProfileProps = Readonly<{ compact: boolean; editable: boolean; hideFields: string[]; + preferences?: Preferences; showAvatar: boolean; showFields: string[]; title: string; @@ -45,6 +63,11 @@ const UserProfile: Component = defineComponent({ editable: {default: true, type: Boolean}, /** Fields to hide by name. */ hideFields: {default: () => [], type: Array as PropType}, + /** Component-level preferences to override global preferences. */ + preferences: { + default: undefined, + type: Object as PropType, + }, /** Whether to render the avatar hero section. */ showAvatar: {default: true, type: Boolean}, /** Fields to show exclusively (empty = show all). */ @@ -53,23 +76,56 @@ const UserProfile: Component = defineComponent({ title: {default: 'Profile', type: String}, }, setup(props: UserProfileProps, {slots}: SetupContext): () => VNode { - const {baseUrl, endpoints, instanceId} = useThunderID(); - const {flattenedProfile, profile, onUpdateProfile} = useUser(); + const {baseUrl, endpoints, instanceId, preferences: contextPreferences} = useThunderID(); + const {flattenedProfile, profile, onUpdateProfile, updateProfile, userSchema} = useUser(); const {t} = useI18n(); + const resolvedPreferences = computed(() => ({ + ...contextPreferences, + ...props.preferences, + user: { + ...contextPreferences?.user, + ...props.preferences?.user, + }, + })); + + const isEditableProfile = computed(() => + resolvedPreferences.value?.user?.fetchUserProfile === false ? false : props.editable, + ); + const error: Ref = ref(null); async function handleProfileUpdate(payload: any): Promise { - if (!baseUrl) return; - error.value = null; try { + const rawProfile = profile?.value?.profile ?? profile?.value; + const updatedAttributes: Record = deepMerge( + (rawProfile?.['attributes'] as Record) ?? {}, + payload, + ); + + Object.keys(updatedAttributes).forEach((key) => { + if (updatedAttributes[key] === undefined || updatedAttributes[key] === null) { + delete updatedAttributes[key]; + } + }); + + if (updateProfile) { + const res = await updateProfile({payload: updatedAttributes} as any); + if (res && !res.success && res.error) { + error.value = res.error; + } + return; + } + + if (!baseUrl) return; + const response: User = await updateMeProfile({ baseUrl, url: resolveResourceEndpoint('usersMe', {endpoints}), instanceId, - payload, + payload: updatedAttributes, }); onUpdateProfile(response); } catch (caughtError: unknown) { @@ -93,15 +149,18 @@ const UserProfile: Component = defineComponent({ class: withVendorCSSClassPrefix('user-profile--styled'), className: props.className, compact: props.compact, - editable: props.editable, + editable: isEditableProfile.value, error: error.value, flattenedProfile: flattenedProfile?.value, hideFields: props.hideFields, - onUpdate: handleProfileUpdate, + onUpdate: isEditableProfile.value ? handleProfileUpdate : undefined, + preferences: resolvedPreferences.value, profile: profile?.value?.profile ?? flattenedProfile?.value, showAvatar: props.showAvatar, showFields: props.showFields, + t: t, title: props.title, + userSchema: userSchema?.value, }, slots, ); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index efdf3e99..f4b23a36 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -161,6 +161,8 @@ export {hasAuthParamsInUrl} from './utils/hasAuthParamsInUrl'; export {navigate} from './utils/navigate'; export {http} from './utils/http'; export {initiateOAuthRedirect} from './utils/oauth'; +export {default as getUsersMeMeta} from './api/getUsersMeMeta'; +export * from './api/getUsersMeMeta'; // ── Phase 4 — Router Helpers ── export {createThunderIDGuard} from './router/guard'; diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index 15715e72..eccfe3ec 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -2,10 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import type { + AttributeSchema, FlowMetadataResponse, HttpRequestConfig, HttpResponse, IdToken, + Preferences, SignInOptions, StorageManager, Theme, @@ -70,6 +72,9 @@ export interface ThunderIDContext { meta?: Readonly>; organizationHandle: string | undefined; + /** User preferences configuration. */ + preferences?: Preferences; + // ── Lifecycle ── reInitialize: (config: Partial) => Promise; @@ -91,6 +96,9 @@ export interface ThunderIDContext { /** The current user object, or `null` if not signed in. */ user: Readonly>; + /** Schema metadata for user type returned by GET /users/me/meta. */ + userSchema?: Readonly | null>>; + /** * Vendor/brand namespace used to prefix storage keys, cookie names, and CSS class names. * Resolved from the `vendor` config option, defaulting to `VendorConstants.VENDOR_PREFIX`. @@ -114,13 +122,13 @@ export interface UserContextValue { profile: Readonly>; /** Refetch the user profile from the server. */ revalidateProfile: () => Promise; - /** - * Update the user profile. Accepts the standard patch request config. - */ - updateProfile: ( + /** Update the user profile. Accepts the standard update request config (PUT /users/me). */ + updateProfile?: ( requestConfig: UpdateMeProfileConfig, sessionId?: string, ) => Promise<{data: {user: User}; error: string; success: boolean}>; + /** Schema metadata for user type returned by GET /users/me/meta. */ + userSchema?: Readonly | null>>; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/vue/src/providers/ThunderIDProvider.ts b/packages/vue/src/providers/ThunderIDProvider.ts index d40946eb..cfcb70b4 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + AttributeSchema, ThunderIDRuntimeError, extractUserClaimsFromIdToken, generateFlattenedUserProfile, @@ -14,7 +15,9 @@ import { SignInOptions, TokenResponse, EmbeddedSignInFlowResponse, + Preferences, getVendorPrefix, + resolveResourceEndpoint, } from '@thunderid/browser'; import { type Component, @@ -36,6 +39,8 @@ import FlowProvider from './FlowProvider'; import I18nProvider from './I18nProvider'; import ThemeProvider from './ThemeProvider'; import UserProvider from './UserProvider'; +import getUsersMe from '../api/getUsersMe'; +import getUsersMeMeta from '../api/getUsersMeMeta'; import {THUNDERID_KEY} from '../keys'; import type {ThunderIDVueConfig} from '../models/config'; import type {ThunderIDContext} from '../models/contexts'; @@ -51,6 +56,7 @@ interface ThunderIDProviderProps { instanceId: number; organizationChain: object | undefined; organizationHandle: string | undefined; + preferences: Preferences | undefined; scopes: string | string[] | undefined; signInOptions: SignInOptions | undefined; signInUrl: string | undefined; @@ -135,6 +141,11 @@ const ThunderIDProvider: Component = defineComponent({ default: undefined, type: String, }, + /** User preferences configuration. */ + preferences: { + default: undefined, + type: Object as PropType, + }, /** The scopes to request. */ scopes: { default: undefined, @@ -185,6 +196,7 @@ const ThunderIDProvider: Component = defineComponent({ const isLoading: Ref = ref(true); const user: ShallowRef = shallowRef(null); const userProfile: ShallowRef = shallowRef(null); + const userSchema: Ref | null> = ref | null>(null); const resolvedBaseUrl: Ref = ref(props.baseUrl); let isUpdatingSession = false; @@ -202,6 +214,7 @@ const ThunderIDProvider: Component = defineComponent({ endpoints: props.endpoints, organizationChain: props.organizationChain, organizationHandle: props.organizationHandle, + preferences: props.preferences, scopes: props.scopes, signInOptions: props.signInOptions, signInUrl: props.signInUrl, @@ -226,15 +239,48 @@ const ThunderIDProvider: Component = defineComponent({ resolvedBaseUrl.value = baseUrl; } + const shouldFetchProfile: boolean = props.preferences?.user?.fetchUserProfile !== false; const claims: User = extractUserClaimsFromIdToken(decodedToken); - user.value = claims; - const profileData: UserProfile = { - flattenedProfile: claims, - profile: claims, + let profileData: User = claims; + const currentSignInStatus: boolean = await client.isSignedIn(); + + if (currentSignInStatus && shouldFetchProfile) { + try { + const fetchedProfile: User = await getUsersMe({ + baseUrl, + url: resolveResourceEndpoint('usersMe', {endpoints: props.endpoints}), + instanceId: props.instanceId, + }); + profileData = {...claims, ...fetchedProfile}; + } catch { + // silent failure, fall back to token claims + } + + try { + const metaRes = await getUsersMeMeta({ + baseUrl, + url: resolveResourceEndpoint('usersMeMeta', {endpoints: props.endpoints}), + instanceId: props.instanceId, + }); + if (metaRes?.schema) { + userSchema.value = metaRes.schema; + } else { + userSchema.value = null; + } + } catch { + userSchema.value = null; + } + } else { + userSchema.value = null; + } + + user.value = profileData; + const profileDataObj: UserProfile = { + flattenedProfile: generateFlattenedUserProfile(profileData), + profile: profileData, }; - userProfile.value = profileData; + userProfile.value = profileDataObj; - const currentSignInStatus: boolean = await client.isSignedIn(); isSignedIn.value = currentSignInStatus; } catch { // silent @@ -326,6 +372,7 @@ const ThunderIDProvider: Component = defineComponent({ isLoading, isSignedIn, organizationHandle: props.organizationHandle, + preferences: props.preferences, reInitialize: async (config: any): Promise => { const result: boolean = await client.reInitialize(config); return typeof result === 'boolean' ? result : true; @@ -339,6 +386,7 @@ const ThunderIDProvider: Component = defineComponent({ signUpUrl: props.signUpUrl, storage: props.storage as ThunderIDVueConfig['storage'], user, + userSchema, vendor, }; @@ -475,18 +523,9 @@ const ThunderIDProvider: Component = defineComponent({ }, profile: userProfile.value, revalidateProfile: async (): Promise => { - try { - const decodedToken: IdToken = await client.getDecodedIdToken(); - const claims: User = extractUserClaimsFromIdToken(decodedToken); - user.value = claims; - userProfile.value = { - flattenedProfile: claims, - profile: claims, - }; - } catch { - // silent - } + await updateSession(); }, + userSchema: userSchema.value, }, { default: (): any => slots['default']?.(), diff --git a/packages/vue/src/providers/UserProvider.ts b/packages/vue/src/providers/UserProvider.ts index 66c5348a..26f2347f 100644 --- a/packages/vue/src/providers/UserProvider.ts +++ b/packages/vue/src/providers/UserProvider.ts @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {UpdateMeProfileConfig, User, UserProfile} from '@thunderid/browser'; +import {AttributeSchema, UpdateMeProfileConfig, User, UserProfile} from '@thunderid/browser'; import { computed, defineComponent, @@ -34,6 +34,7 @@ interface UserProviderProps { sessionId?: string, ) => Promise<{data: {user: User}; error: string; success: boolean}>) | undefined; + userSchema?: Record | null; } const UserProvider: Component = defineComponent({ @@ -41,11 +42,11 @@ const UserProvider: Component = defineComponent({ props: { /** Callback to sync a successfully-saved profile back up to ThunderIDProvider. */ onUpdateProfile: {default: undefined, type: Function as PropType<(payload: User) => void>}, - /** The full user profile data (nested + flat + schemas). */ + /** The full user profile data (nested + flat). */ 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 PATCH. */ + /** Update the user profile via PUT. */ updateProfile: { default: undefined, type: Function as PropType< @@ -55,25 +56,24 @@ const UserProvider: Component = defineComponent({ ) => Promise<{data: {user: User}; error: string; success: boolean}> >, }, + /** User schema metadata. */ + userSchema: {default: null, type: Object as PropType | null>}, }, setup(props: UserProviderProps, {slots}: SetupContext): () => VNode { - // Derive flattenedProfile from the single profile prop, - // matching the same pattern as the React SDK's UserProvider. + // Derive flattenedProfile and userSchema from props const profileRef: Ref = computed(() => props.profile); const flattenedProfileRef: Ref = computed(() => props.profile?.flattenedProfile ?? null); + const userSchemaRef: Ref | null> = computed( + () => (props.profile as any)?.userSchema ?? props.userSchema ?? null, + ); const context: UserContextValue = { flattenedProfile: flattenedProfileRef as unknown as Readonly>, onUpdateProfile: props.onUpdateProfile ?? ((): void => {}), profile: profileRef as unknown as Readonly>, revalidateProfile: props.revalidateProfile, - updateProfile: - props.updateProfile ?? - (async (): Promise<{data: {user: User}; error: string; success: boolean}> => ({ - data: {user: {} as User}, - error: 'updateProfile callback not provided', - success: false, - })), + updateProfile: props.updateProfile, + userSchema: userSchemaRef as unknown as Readonly | null>>, }; provide(USER_KEY, context);