diff --git a/packages/nuxt/src/runtime/components/ThunderIDRoot.ts b/packages/nuxt/src/runtime/components/ThunderIDRoot.ts index 44f395b9..231226c7 100644 --- a/packages/nuxt/src/runtime/components/ThunderIDRoot.ts +++ b/packages/nuxt/src/runtime/components/ThunderIDRoot.ts @@ -19,7 +19,7 @@ import {useState, useRuntimeConfig} from '#imports'; * - {@link I18nProvider} ← `preferences.i18n` * - {@link ThemeProvider} ← `mode` * - {@link FlowProvider} - * - {@link UserProvider} ← `profile`, `flattenedProfile`, `schemas`, + * - {@link UserProvider} ← `profile`, `flattenedProfile`, `userSchema`, * `updateProfile`, `revalidateProfile`, `onUpdateProfile` * * The `THUNDERID_KEY` (config + auth state + actions) is still provided at the @@ -130,12 +130,18 @@ const ThunderIDRoot: Component = defineComponent({ }; /** - * Re-fetch the full user profile from `/api/auth/user/profile`. + * Re-fetch the full user profile (and its attribute schema) from `/api/auth/user/profile`. */ const revalidateProfile = async (): Promise => { try { - const res: UserProfile = await $fetch('/api/auth/user/profile'); - if (res) userProfileState.value = res; + const res: (UserProfile & {userSchema?: Record | null}) | null = await $fetch< + UserProfile & {userSchema?: Record | null} + >('/api/auth/user/profile'); + if (res) { + const {userSchema: fetchedSchema, ...profile} = res; + userProfileState.value = profile as UserProfile; + userSchemaState.value = fetchedSchema ?? null; + } } catch { // Non-fatal — profile stays stale until the next navigation. } diff --git a/packages/nuxt/src/runtime/components/user/UserDropdown.ts b/packages/nuxt/src/runtime/components/user/UserDropdown.ts index cdf365f5..8c282102 100644 --- a/packages/nuxt/src/runtime/components/user/UserDropdown.ts +++ b/packages/nuxt/src/runtime/components/user/UserDropdown.ts @@ -32,7 +32,7 @@ const UserDropdown: Component = defineComponent({ }, setup(props: {className: string}, {slots, emit}: {emit: any; slots: any}): () => VNode | VNode[] | null { const {user, signOut} = useThunderID(); - useUser(); + const {revalidateProfile} = useUser(); const isProfileModalOpen: Ref = ref(false); return (): VNode | VNode[] | null => @@ -44,6 +44,7 @@ const UserDropdown: Component = defineComponent({ isProfileModalOpen: isProfileModalOpen.value, onProfileClick: (): void => { isProfileModalOpen.value = true; + void revalidateProfile(); emit('profileClick'); }, onProfileModalClose: (): void => { diff --git a/packages/nuxt/src/runtime/components/user/UserProfile.ts b/packages/nuxt/src/runtime/components/user/UserProfile.ts index 196b89da..892a3fd0 100644 --- a/packages/nuxt/src/runtime/components/user/UserProfile.ts +++ b/packages/nuxt/src/runtime/components/user/UserProfile.ts @@ -42,7 +42,7 @@ const UserProfile: Component = defineComponent({ }>, {slots}: SetupContext, ): () => VNode { - const {flattenedProfile, schemas, updateProfile} = useUser(); + const {flattenedProfile, profile, updateProfile, userSchema} = useUser(); return (): VNode => h( @@ -55,9 +55,10 @@ const UserProfile: Component = defineComponent({ flattenedProfile: flattenedProfile?.value, hideFields: props.hideFields, onUpdate: updateProfile, - schemas: schemas?.value, + profile: profile?.value, showFields: props.showFields, title: props.title, + userSchema: userSchema?.value, }, slots, ); 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 2b61f2bd..feee699c 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 @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {UserProfile} from '@thunderid/node'; +import type {AttributeSchema, UserProfile} from '@thunderid/node'; import {defineEventHandler, createError} from 'h3'; import type {H3Event} from 'h3'; import ThunderIDNuxtClient from '../../../ThunderIDNuxtClient'; @@ -11,30 +11,47 @@ import {useRuntimeConfig} from '#imports'; /** * GET /api/auth/user/profile * - * Returns the full {@link UserProfile} (with `flattenedProfile`) for the authenticated user. Used by `ThunderIDRoot.revalidateProfile` - * to refresh client-side state after a profile update. + * Returns the full {@link UserProfile} (with `flattenedProfile`) plus the `users/me/meta` + * attribute schema for the authenticated user. Used by `ThunderIDRoot.revalidateProfile` to + * refresh client-side state — both after a profile update, and to self-heal cases where the + * SSR-seeded state was empty. * * Mirrors `getUserProfileAction` in the Next.js SDK. */ -export default defineEventHandler(async (event: H3Event): Promise => { - const config: ReturnType = useRuntimeConfig(); - const sessionSecret: string | undefined = config.thunderid?.sessionSecret; +export default defineEventHandler( + async (event: H3Event): Promise | null}> => { + const config: ReturnType = useRuntimeConfig(); + const sessionSecret: string | undefined = config.thunderid?.sessionSecret; - const session: Awaited> = await verifyAndRehydrateSession( - event, - sessionSecret, - ); - if (!session) { - throw createError({statusCode: 401, statusMessage: 'Unauthorized: Invalid or expired session.'}); - } + const session: Awaited> = await verifyAndRehydrateSession( + event, + sessionSecret, + ); + if (!session) { + throw createError({statusCode: 401, statusMessage: 'Unauthorized: Invalid or expired session.'}); + } - try { const client: ThunderIDNuxtClient = ThunderIDNuxtClient.getInstance(); - return await client.getUserProfile(session.sessionId); - } catch (err) { - throw createError({ - statusCode: 500, - statusMessage: `Failed to retrieve user profile: ${err instanceof Error ? err.message : String(err)}`, - }); - } -}); + + let userProfile: UserProfile; + try { + userProfile = await client.getUserProfile(session.sessionId); + } catch (err) { + throw createError({ + statusCode: 500, + statusMessage: `Failed to retrieve user profile: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + // Schema fetch failures shouldn't fail the whole response — the profile itself is still + // useful without it, same resilience the SSR plugin applies to this same pair of calls. + let userSchema: Record | null = null; + try { + userSchema = await client.getUserSchema(session.sessionId); + } catch { + userSchema = null; + } + + return {...userProfile, userSchema}; + }, +);