diff --git a/packages/express/src/ThunderIDExpressClient.ts b/packages/express/src/ThunderIDExpressClient.ts index ed79b2b8..b39dcb6f 100644 --- a/packages/express/src/ThunderIDExpressClient.ts +++ b/packages/express/src/ThunderIDExpressClient.ts @@ -44,10 +44,17 @@ class ThunderIDExpressClient { - const sessionId: string | undefined = req.cookies?.[this.getSessionCookieName()]; + const cookies = req.cookies as Record | undefined; + const sessionId: string | undefined = cookies?.[this.getSessionCookieName()]; return this.getUser(sessionId); } + public async updateUserCredentialsFromRequest(req: express.Request, payload: Record): Promise { + const cookies = req.cookies as Record | undefined; + const sessionId: string | undefined = cookies?.[this.getSessionCookieName()]; + return this.updateUserCredentials(payload, sessionId); + } + public override async signIn( req: express.Request, res: express.Response, diff --git a/packages/express/src/__tests__/ThunderIDExpressClient.test.ts b/packages/express/src/__tests__/ThunderIDExpressClient.test.ts new file mode 100644 index 00000000..b35b60dd --- /dev/null +++ b/packages/express/src/__tests__/ThunderIDExpressClient.test.ts @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import express from 'express'; +import {describe, it, expect, vi} from 'vitest'; +import ThunderIDExpressClient from '../ThunderIDExpressClient'; + +describe('ThunderIDExpressClient', () => { + describe('updateUserCredentialsFromRequest', () => { + it('extracts session cookie and calls updateUserCredentials', async () => { + const client = new ThunderIDExpressClient(); + await client.initialize({ + baseUrl: 'https://auth.example.com', + clientId: 'test-client', + }); + + const updateCredentialsSpy = vi.spyOn(client, 'updateUserCredentials').mockResolvedValue(undefined); + + const cookieName = client.getSessionCookieName(); + const req = { + cookies: { + [cookieName]: 'session-cookie-123', + }, + } as unknown as express.Request; + + const payload = {password: 'new-password'}; + await client.updateUserCredentialsFromRequest(req, payload); + + expect(updateCredentialsSpy).toHaveBeenCalledWith(payload, 'session-cookie-123'); + }); + }); +}); diff --git a/packages/nextjs/src/ThunderIDNextClient.ts b/packages/nextjs/src/ThunderIDNextClient.ts index b3759bc5..3aab4c10 100644 --- a/packages/nextjs/src/ThunderIDNextClient.ts +++ b/packages/nextjs/src/ThunderIDNextClient.ts @@ -21,6 +21,7 @@ import { generateFlattenedUserProfile, getUsersMe, getUsersMeMeta, + updateMeCredentials, updateMeProfile, resolveResourceEndpoint, } from '@thunderid/node'; @@ -202,6 +203,37 @@ class ThunderIDNextClient e } } + /** + * Updates one or more of the signed-in user's credentials (e.g. `password`, or any other + * attribute the user type schema declares `credential: true`). + * + * Not part of {@link ThunderIDJavaScriptClient}'s base surface — unlike `updateUserProfile`, + * no other SDK routes credential updates through a client method (React/Vue's + * `ChangeCredential` call the core `updateMeCredentials` function directly), so this is a + * Next.js-specific addition rather than an override. Kept here anyway so this SDK's own + * server actions have one consistent way to reach every `/users/me/*` operation. + * + * Deliberately does not catch and rewrap errors the way `updateUserProfile` does: the caller + * (`updateUserCredentialsAction`) needs the real `ThunderIDAPIError` instance, status code + * included, to map a failure onto the right form field before it crosses the server action + * boundary back to the client. + */ + override async updateUserCredentials(payload: Record, userId?: string): Promise { + await this.ensureInitialized(); + + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = configData?.baseUrl; + + await updateMeCredentials({ + baseUrl, + url: resolveResourceEndpoint('usersMeCredentials', configData), + headers: { + Authorization: `Bearer ${await this.getAccessToken(userId)}`, + }, + payload, + }); + } + override isLoading(): boolean { return false; } diff --git a/packages/nextjs/src/client/components/presentation/ChangeCredential/ChangeCredential.tsx b/packages/nextjs/src/client/components/presentation/ChangeCredential/ChangeCredential.tsx new file mode 100644 index 00000000..f73b43b5 --- /dev/null +++ b/packages/nextjs/src/client/components/presentation/ChangeCredential/ChangeCredential.tsx @@ -0,0 +1,149 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {CredentialConstants, PasswordPolicy, resolveChangeCredentialPolicy, supportsCredential} from '@thunderid/node'; +import { + BaseChangeCredential, + BaseChangeCredentialProps, + ChangePasswordValues, + useTranslation, + useUser, +} from '@thunderid/react'; +import {FC, ReactElement, useMemo, useState} from 'react'; +import getSessionId from '../../../../server/actions/getSessionId'; +import updateUserCredentialsAction, { + UpdateUserCredentialsActionResult, +} from '../../../../server/actions/updateUserCredentialsAction'; +import useThunderID from '../../../contexts/ThunderID/useThunderID'; + +/** + * Title-cases a credential attribute name for use as a display-name fallback when the schema + * declares no `displayName` for it, e.g. `pin` -> `Pin`. + */ +const defaultDisplayName = (attribute: string): string => attribute.charAt(0).toUpperCase() + attribute.slice(1); + +/** + * Props for the ChangeCredential component. + * Mirrors `@thunderid/react`'s `ChangeCredentialProps` exactly, so a consumer moving between + * the React and Next.js SDKs doesn't need to change how they call it. + */ +export type ChangeCredentialProps = Omit< + BaseChangeCredentialProps, + 'credentialDisplayName' | 'error' | 'fieldErrors' | 'loading' | 'onSubmit' | 'success' +> & { + /** + * The credential attribute this instance manages, any attribute the user's entity type + * schema declares `credential: true` (for example `password` or `pin`). Defaults to + * `password`. Render the component once per credential to let a user manage more than one, + * for example `` for the password and + * `` for a PIN. + */ + attribute?: string; + /** + * Called after the credential has been changed successfully. + */ + onSuccess?: () => void; +}; + +/** + * ChangeCredential lets the signed-in user set a new value for one of their own credentials. + * + * This is the Next.js-specific implementation: it uses `BaseChangeCredential` from + * `@thunderid/react` for rendering, but routes the write through + * `updateUserCredentialsAction` (a server action) instead of calling the ThunderID server + * directly from the browser, matching how `UserProfile` reaches `updateUserProfileAction`. + * + * Defaults to managing the `password` credential. To manage a different one (for example a + * PIN declared on the user type schema), set `attribute`; render the component once per + * credential to let a user manage several. + * + * @example + * ```tsx + * // Basic usage, manages the password + * toast('Password updated')} /> + * + * // Managing a different credential declared on the schema + * + * ``` + */ +const ChangeCredential: FC = ({ + attribute = CredentialConstants.PASSWORD, + onSuccess, + policy, + preferences, + ...rest +}: ChangeCredentialProps): ReactElement => { + const {preferences: contextPreferences} = useThunderID(); + const {userSchema} = useUser(); + const resolvedDisplayName: string = userSchema?.[attribute]?.displayName ?? defaultDisplayName(attribute); + + const resolvedPreferences = useMemo( + () => ({ + ...contextPreferences, + ...preferences, + user: {...contextPreferences?.user, ...preferences?.user}, + }), + [contextPreferences, preferences], + ); + const {t} = useTranslation(resolvedPreferences?.i18n); + + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + const resolvedPolicy: PasswordPolicy = useMemo( + () => resolveChangeCredentialPolicy(userSchema, attribute, policy), + [userSchema, attribute, policy], + ); + + const handleSubmit = async ({newPassword}: ChangePasswordValues): Promise => { + setError(null); + setFieldErrors({}); + setSuccess(false); + setLoading(true); + + const result: UpdateUserCredentialsActionResult = await updateUserCredentialsAction( + {[attribute]: newPassword}, + await getSessionId(), + ); + + if (result.success) { + setSuccess(true); + onSuccess?.(); + } else { + const text: string = + result.message ?? + t(result.messageKey, {credential: resolvedDisplayName, credentialLower: resolvedDisplayName.toLowerCase()}); + + if (result.field) { + setFieldErrors({[result.field]: text}); + } else { + setError(text); + } + } + + setLoading(false); + }; + + return ( + { + void handleSubmit(values); + }} + /> + ); +}; + +export default ChangeCredential; diff --git a/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx b/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx index d11e8477..76cea9f0 100644 --- a/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx +++ b/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx @@ -36,6 +36,18 @@ export type UserDropdownProps = Omit ReactNode; + /** + * Called instead of opening the built-in "Manage Profile" popup when the "Manage Profile" + * menu item (see `manageProfileLabel`) is clicked. Use this when the app has its own + * profile/account page it wants to navigate to instead, for example with the router's + * `push()`. + * + * @example + * ```tsx + * router.push('/account')} /> + * ``` + */ + onManageProfile?: () => void; /** * Custom render function for the dropdown content. * When provided, this replaces just the dropdown content while keeping the trigger. @@ -98,6 +110,7 @@ const UserDropdown: FC = ({ children, renderTrigger, renderDropdown, + onManageProfile: onManageProfileOverride, onSignOut, ...rest }: UserDropdownProps): ReactElement => { @@ -105,6 +118,10 @@ const UserDropdown: FC = ({ const [isProfileOpen, setIsProfileOpen] = useState(false); const handleManageProfile = (): void => { + if (onManageProfileOverride) { + onManageProfileOverride(); + return; + } setIsProfileOpen(true); }; @@ -134,7 +151,7 @@ const UserDropdown: FC = ({ return ( <> {children(renderProps)} - + {!onManageProfileOverride && } ); } @@ -157,7 +174,7 @@ const UserDropdown: FC = ({ /> )} {/* Note: renderDropdown would need BaseUserDropdown modifications to implement properly */} - + {!onManageProfileOverride && } ); } @@ -172,7 +189,9 @@ const UserDropdown: FC = ({ onSignOut={handleSignOut} {...rest} /> - {isProfileOpen && } + {!onManageProfileOverride && isProfileOpen && ( + + )} ); }; diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index cb90eced..06a4f2f8 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -23,7 +23,6 @@ import { ThunderIDContext as ReactThunderIDContext, ThunderIDContextProps as ReactThunderIDContextProps, ThunderIDProviderProps, - getActiveTheme, } from '@thunderid/react'; import {ReadonlyURLSearchParams} from 'next/dist/client/components/navigation.react-server'; import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared-runtime'; @@ -405,7 +404,7 @@ const ThunderIDClientProvider: FC - + , + sessionId?: string, +): Promise => { + try { + const client = getClient(); + await client.updateUserCredentials(payload, sessionId); + return {field: null, messageKey: '', success: true}; + } catch (error) { + const {field, message, messageKey}: CredentialUpdateErrorResult = mapCredentialUpdateError(error); + return {field, message, messageKey, success: false}; + } +}; + +export default updateUserCredentialsAction; diff --git a/packages/node/src/ThunderIDNodeClient.ts b/packages/node/src/ThunderIDNodeClient.ts index 729c52d3..27622803 100644 --- a/packages/node/src/ThunderIDNodeClient.ts +++ b/packages/node/src/ThunderIDNodeClient.ts @@ -2,15 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { - ThunderIDJavaScriptClient, - ThunderIDAuthException, + AuthClientConfig, ExtendedAuthorizeRequestUrlParams, IdToken, OIDCEndpoints, + resolveResourceEndpoint, SessionData, Storage, + ThunderIDAuthException, + ThunderIDJavaScriptClient, TokenExchangeRequestConfig, TokenResponse, + updateMeCredentials, User, } from '@thunderid/javascript'; import AuthURLCallback from './models/AuthURLCallback'; @@ -19,7 +22,7 @@ import MemoryCacheStore from './stores/MemoryCacheStore'; import NodeCryptoUtils from './utils/NodeCryptoUtils'; import SessionUtils from './utils/SessionUtils'; -class ThunderIDNodeClient extends ThunderIDJavaScriptClient { +class ThunderIDNodeClient extends ThunderIDJavaScriptClient { private _nodeInstanceId = 0; public constructor(instanceId = 0) { @@ -203,6 +206,24 @@ class ThunderIDNodeClient extends ThunderIDJavaScriptCl ): Promise { return super.exchangeToken(config, userId); } + + /** + * Updates one or more of the signed-in user's credentials (e.g. `password`, or any other + * attribute the user type schema declares `credential: true`). + */ + public async updateUserCredentials(payload: Record, userId?: string): Promise { + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = configData?.baseUrl; + + await updateMeCredentials({ + baseUrl, + headers: { + Authorization: `Bearer ${await this.getAccessToken(userId)}`, + }, + payload, + url: resolveResourceEndpoint('usersMeCredentials', configData), + }); + } } export default ThunderIDNodeClient; diff --git a/packages/node/src/__tests__/ThunderIDNodeClient.test.ts b/packages/node/src/__tests__/ThunderIDNodeClient.test.ts new file mode 100644 index 00000000..a119dcce --- /dev/null +++ b/packages/node/src/__tests__/ThunderIDNodeClient.test.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import * as thunderJS from '@thunderid/javascript'; +import {describe, it, expect, vi} from 'vitest'; +import ThunderIDNodeClient from '../ThunderIDNodeClient'; + +vi.mock('@thunderid/javascript', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateMeCredentials: vi.fn().mockResolvedValue(undefined), + }; +}); + +describe('ThunderIDNodeClient', () => { + describe('updateUserCredentials', () => { + it('calls updateMeCredentials with the correct payload and authorization header', async () => { + const client = new ThunderIDNodeClient(); + await client.initialize({ + baseUrl: 'https://auth.example.com', + clientId: 'test-client', + }); + + vi.spyOn(client, 'getAccessToken').mockResolvedValue('test-access-token'); + + const payload = {password: 'new-secure-password!'}; + await client.updateUserCredentials(payload, 'user-123'); + + expect(thunderJS.updateMeCredentials).toHaveBeenCalledWith({ + baseUrl: 'https://auth.example.com', + headers: { + Authorization: 'Bearer test-access-token', + }, + payload, + url: undefined, + }); + }); + }); +}); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 27d439d6..f2b90987 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -176,6 +176,11 @@ export default defineNuxtModule({ method: 'patch' as const, route: NuxtAPIRoutes.USER_PROFILE, }, + { + handler: resolve('./runtime/server/routes/auth/user/credentials.patch'), + method: 'patch' as const, + route: NuxtAPIRoutes.USER_CREDENTIALS, + }, ]; serverRoutes.forEach((sr: ServerRoute): void => { @@ -249,6 +254,7 @@ export default defineNuxtModule({ addComponent({filePath: resolve('./runtime/components/user/User'), name: 'User'}); addComponent({filePath: resolve('./runtime/components/user/UserProfile'), name: 'UserProfile'}); addComponent({filePath: resolve('./runtime/components/user/UserDropdown'), name: 'UserDropdown'}); + addComponent({filePath: resolve('./runtime/components/user/ChangeCredential'), name: 'ChangeCredential'}); // ── Auth callback ──────────────────────────────────────────────────────── addComponent({filePath: resolve('./runtime/components/auth/Callback'), name: 'Callback'}); diff --git a/packages/nuxt/src/runtime/components/ThunderIDRoot.ts b/packages/nuxt/src/runtime/components/ThunderIDRoot.ts index 311fcf09..8ef8736d 100644 --- a/packages/nuxt/src/runtime/components/ThunderIDRoot.ts +++ b/packages/nuxt/src/runtime/components/ThunderIDRoot.ts @@ -72,9 +72,8 @@ const ThunderIDRoot: Component = defineComponent({ // Gate flags — mirror the same checks in thunderid-ssr.ts so client props // always agree with what the Nitro plugin decided to fetch server-side. const shouldFetchProfile: boolean = prefs?.user?.fetchUserProfile !== false; - // Defaults to 'light' — matches the Vue SDK's ThunderIDProvider, which - // passes no mode and therefore uses ThemeProvider's `DEFAULT_THEME`. - const themeMode: string = prefs?.theme?.mode ?? 'light'; + // Defaults to 'class' so it dynamically detects theme classes (e.g. .dark) on document.documentElement. + const themeMode: string = prefs?.theme?.mode ?? 'class'; // ── Callbacks ────────────────────────────────────────────────────────── diff --git a/packages/nuxt/src/runtime/components/user/ChangeCredential.ts b/packages/nuxt/src/runtime/components/user/ChangeCredential.ts new file mode 100644 index 00000000..60ca6e68 --- /dev/null +++ b/packages/nuxt/src/runtime/components/user/ChangeCredential.ts @@ -0,0 +1,168 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + CredentialConstants, + resolveChangeCredentialPolicy, + supportsCredential, + withVendorCSSClassPrefix, + type CredentialUpdateErrorResult, + type PasswordPolicy, + type Preferences, +} from '@thunderid/browser'; +import {BaseChangeCredential, type ChangePasswordValues, useI18n} from '@thunderid/vue'; +import { + type Component, + type ComputedRef, + type PropType, + type Ref, + type SetupContext, + type VNode, + computed, + defineComponent, + h, + ref, +} from 'vue'; +import NuxtAPIRoutes from '../../constants/NuxtAPIRoutes'; +import {useUser} from '#imports'; + +/** + * Title-cases a credential attribute name for use as a display-name fallback when the schema + * declares no `displayName` for it, e.g. `pin` -> `Pin`. + */ +const defaultDisplayName = (attribute: string): string => attribute.charAt(0).toUpperCase() + attribute.slice(1); + +interface UpdateUserCredentialsResult extends CredentialUpdateErrorResult { + success: boolean; +} + +type ChangeCredentialProps = Readonly<{ + attribute: string; + cardLayout: boolean; + className: string; + policy?: PasswordPolicy; + preferences?: Preferences; + showRequirements: boolean; + title?: string; +}>; + +/** + * Nuxt-specific ChangeCredential container. + * + * Reads `userSchema` from `useUser()` (Nuxt auto-import, re-exported from `@thunderid/vue`) and + * delegates rendering to {@link BaseChangeCredential} from `@thunderid/vue`, same as + * `UserProfile` does for the profile form. + * + * The write itself goes through `PATCH /api/auth/user/credentials` (`NuxtAPIRoutes. + * USER_CREDENTIALS`) rather than calling the ThunderID server directly from the browser — + * matching `UserProfile`'s own `updateProfile`/`NuxtAPIRoutes.USER_PROFILE` round trip, and the + * same reason `ThunderIDRoot.ts`'s `fetchMeta` proxies flow metadata: the browser never talks + * to the ThunderID server directly, so no CORS configuration is required there. + * + * Preserves the same prop/event API as the Vue SDK's `ChangeCredential` component so consumers + * don't need to change their templates. + * + * @example + * ```vue + * + * + * ``` + */ +const ChangeCredential: Component = defineComponent({ + name: 'ChangeCredential', + props: { + attribute: {default: CredentialConstants.PASSWORD, type: String}, + cardLayout: {default: false, type: Boolean}, + className: {default: '', type: String}, + policy: {default: undefined, type: Object as PropType}, + preferences: {default: undefined, type: Object as PropType}, + showRequirements: {default: true, type: Boolean}, + title: {default: undefined, type: String}, + }, + emits: ['success'], + setup(props: ChangeCredentialProps, {emit}: SetupContext): () => VNode { + const {userSchema} = useUser(); + const {t} = useI18n(); + + const resolvedDisplayName: ComputedRef = computed( + () => userSchema?.value?.[props.attribute]?.displayName ?? defaultDisplayName(props.attribute), + ); + + const error: Ref = ref(null); + const fieldErrors: Ref> = ref>({}); + const loading: Ref = ref(false); + const success: Ref = ref(false); + + const resolvedPolicy: ComputedRef = computed(() => + resolveChangeCredentialPolicy(userSchema?.value, props.attribute, props.policy), + ); + + async function handleSubmit({newPassword}: ChangePasswordValues): Promise { + error.value = null; + fieldErrors.value = {}; + success.value = false; + loading.value = true; + + try { + const result: UpdateUserCredentialsResult = await $fetch(NuxtAPIRoutes.USER_CREDENTIALS, { + body: {payload: {[props.attribute]: newPassword}}, + method: 'PATCH', + }); + + if (result.success) { + success.value = true; + emit('success'); + } else { + const text: string = + result.message ?? + t(result.messageKey, { + credential: resolvedDisplayName.value, + credentialLower: resolvedDisplayName.value.toLowerCase(), + }); + + if (result.field) { + fieldErrors.value = {[result.field]: text}; + } else { + error.value = text; + } + } + } catch (caughtError: unknown) { + // The Nitro route itself failed to respond (network error, session expired outright), + // as opposed to the credential write it performed failing — that case resolves with + // `success: false` above instead of throwing. + error.value = + caughtError instanceof Error && caughtError.message + ? caughtError.message + : t('user.change_password.generic.error', { + credential: resolvedDisplayName.value, + credentialLower: resolvedDisplayName.value.toLowerCase(), + }); + } finally { + loading.value = false; + } + } + + return (): VNode => + h(BaseChangeCredential, { + cardLayout: props.cardLayout, + class: withVendorCSSClassPrefix('change-credential--styled'), + className: props.className, + credentialDisplayName: resolvedDisplayName.value, + error: error.value, + fieldErrors: fieldErrors.value, + loading: loading.value, + onSubmit: (values: ChangePasswordValues): void => { + void handleSubmit(values); + }, + policy: resolvedPolicy.value, + preferences: props.preferences, + showRequirements: props.showRequirements, + success: success.value, + t, + title: props.title, + unavailable: !supportsCredential(userSchema?.value, props.attribute), + }); + }, +}); + +export default ChangeCredential; diff --git a/packages/nuxt/src/runtime/components/user/UserDropdown.ts b/packages/nuxt/src/runtime/components/user/UserDropdown.ts index 8c282102..1b5a4d6b 100644 --- a/packages/nuxt/src/runtime/components/user/UserDropdown.ts +++ b/packages/nuxt/src/runtime/components/user/UserDropdown.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import {withVendorCSSClassPrefix} from '@thunderid/browser'; -import {BaseUserDropdown, UserProfile as UserProfileComponent} from '@thunderid/vue'; -import {type Component, type Ref, type VNode, defineComponent, h, ref} from 'vue'; +import {BaseUserDropdown, type DropdownMenuItem, UserProfile as UserProfileComponent} from '@thunderid/vue'; +import {type Component, type PropType, type Ref, type VNode, defineComponent, h, ref} from 'vue'; import {useThunderID, useUser} from '#imports'; /** @@ -19,9 +19,9 @@ import {useThunderID, useUser} from '#imports'; * The embedded profile modal renders the Nuxt-specific `UserProfile` so that * profile update handlers are also wired through the Nuxt auto-import layer. * - * @example + * @example With a custom account page instead of the built-in profile popup * ```vue - * + * * ``` */ const UserDropdown: Component = defineComponent({ @@ -29,8 +29,31 @@ const UserDropdown: Component = defineComponent({ name: 'UserDropdown', props: { className: {default: '', type: String}, + menuAlign: {default: 'auto', type: String as PropType<'auto' | 'left' | 'right'>}, + menuItems: {default: () => [], type: Array as PropType}, + /** + * Called instead of opening the built-in profile popup when the profile menu item is + * clicked. Use this when the app has its own account page to navigate to. + */ + onManageProfile: {default: undefined, type: Function as PropType<() => void>}, + profileLabel: {default: 'Profile', type: String}, + showChevron: {default: true, type: Boolean}, + showTriggerLabel: {default: false, type: Boolean}, + size: {default: 'md', type: String as PropType<'sm' | 'md' | 'lg'>}, }, - setup(props: {className: string}, {slots, emit}: {emit: any; slots: any}): () => VNode | VNode[] | null { + setup( + props: Readonly<{ + className: string; + menuAlign: 'auto' | 'left' | 'right'; + menuItems: DropdownMenuItem[]; + onManageProfile?: () => void; + profileLabel: string; + showChevron: boolean; + showTriggerLabel: boolean; + size: 'sm' | 'md' | 'lg'; + }>, + {slots, emit}: {emit: any; slots: any}, + ): () => VNode | VNode[] | null { const {user, signOut} = useThunderID(); const {revalidateProfile} = useUser(); const isProfileModalOpen: Ref = ref(false); @@ -42,7 +65,13 @@ const UserDropdown: Component = defineComponent({ class: withVendorCSSClassPrefix('user-dropdown--styled'), className: props.className, isProfileModalOpen: isProfileModalOpen.value, + menuAlign: props.menuAlign, + menuItems: props.menuItems, onProfileClick: (): void => { + if (props.onManageProfile) { + props.onManageProfile(); + return; + } isProfileModalOpen.value = true; void revalidateProfile(); emit('profileClick'); @@ -58,12 +87,17 @@ const UserDropdown: Component = defineComponent({ // Nuxt UserProfile container; UserProfileComponent from @thunderid/vue // reads its data from the OrganizationProvider / UserProvider context // wired up by ThunderIDRoot, so it works identically. - profileContent: isProfileModalOpen.value - ? h(UserProfileComponent, { - cardLayout: false, - editable: true, - }) - : null, + profileContent: + !props.onManageProfile && isProfileModalOpen.value + ? h(UserProfileComponent, { + cardLayout: false, + editable: true, + }) + : null, + profileLabel: props.profileLabel, + showChevron: props.showChevron, + showTriggerLabel: props.showTriggerLabel, + size: props.size, user: user.value, }, slots, diff --git a/packages/nuxt/src/runtime/components/user/UserProfile.ts b/packages/nuxt/src/runtime/components/user/UserProfile.ts index 892a3fd0..5648fec1 100644 --- a/packages/nuxt/src/runtime/components/user/UserProfile.ts +++ b/packages/nuxt/src/runtime/components/user/UserProfile.ts @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {withVendorCSSClassPrefix} from '@thunderid/browser'; +import {deepMerge, withVendorCSSClassPrefix} from '@thunderid/browser'; import {BaseUserProfile} from '@thunderid/vue'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; import {useUser} from '#imports'; @@ -44,6 +44,30 @@ const UserProfile: Component = defineComponent({ ): () => VNode { const {flattenedProfile, profile, updateProfile, userSchema} = useUser(); + // `BaseUserProfile` saves one field at a time (`onUpdate({fieldName: value})`), but + // `PATCH /api/auth/user/profile` -> the backend's `PUT /users/me` validates the submitted + // attributes against the full schema, including fields the caller didn't touch. Merge the + // edited field onto the rest of the stored profile before sending, the same way + // `@thunderid/react`'s and `@thunderid/vue`'s own `UserProfile` containers do, so a + // single-field edit doesn't get rejected for omitting every other required attribute. + async function handleProfileUpdate(payload: Record): Promise { + if (!updateProfile) return; + + const rawProfile = (profile?.value?.profile ?? profile?.value) as Record | undefined; + const updatedAttributes: Record = deepMerge( + (rawProfile?.attributes as Record) ?? {}, + payload, + ); + + Object.keys(updatedAttributes).forEach((key) => { + if (updatedAttributes[key] === undefined || updatedAttributes[key] === null) { + delete updatedAttributes[key]; + } + }); + + await updateProfile({payload: updatedAttributes} as any); + } + return (): VNode => h( BaseUserProfile, @@ -54,8 +78,8 @@ const UserProfile: Component = defineComponent({ editable: props.editable, flattenedProfile: flattenedProfile?.value, hideFields: props.hideFields, - onUpdate: updateProfile, - profile: profile?.value, + onUpdate: updateProfile ? handleProfileUpdate : undefined, + profile: profile?.value?.profile ?? flattenedProfile?.value, showFields: props.showFields, title: props.title, userSchema: userSchema?.value, diff --git a/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts b/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts index de08edbe..3df666de 100644 --- a/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts +++ b/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts @@ -25,6 +25,7 @@ const NuxtAPIRoutes: { SIGN_UP: string; TOKEN: string; USER: string; + USER_CREDENTIALS: string; USER_PROFILE: string; } = { /** Resolves the OAuth callback and completes sign-in. */ @@ -43,6 +44,8 @@ const NuxtAPIRoutes: { TOKEN: '/api/auth/token', /** Returns the current user object. */ USER: '/api/auth/user', + /** Updates (PATCH) one of the signed-in user's own credentials, e.g. `password`. */ + USER_CREDENTIALS: '/api/auth/user/credentials', /** Fetches (GET) and updates (PATCH) the user profile. */ USER_PROFILE: '/api/auth/user/profile', } as const; diff --git a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts index f55f5bf7..47d0f249 100644 --- a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts +++ b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts @@ -11,6 +11,7 @@ import { getUsersMeMeta, isEmpty, resolveResourceEndpoint, + updateMeCredentials, updateMeProfile, type AttributeSchema, type AuthClientConfig, @@ -244,6 +245,28 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { } } + /** + * Updates one or more of the signed-in user's credentials (e.g. `password`, or any other + * attribute the user type schema declares `credential: true`). + * + * Deliberately does not catch and rewrap errors the way `updateUserProfile` does: the caller + * (the `PATCH /api/auth/user/credentials` Nitro route) needs the real `ThunderIDAPIError` + * instance, status code included, to map a failure onto the right form field. + */ + async updateUserCredentials(payload: Record, sessionId?: string): Promise { + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = configData?.baseUrl; + + await updateMeCredentials({ + baseUrl, + url: resolveResourceEndpoint('usersMeCredentials', configData), + headers: { + Authorization: `Bearer ${await this.getAccessToken(sessionId)}`, + }, + payload, + }); + } + async getUserSchema(sessionId?: string): Promise | null> { const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); const baseUrl: string | undefined = configData?.baseUrl; diff --git a/packages/nuxt/src/runtime/server/routes/auth/user/credentials.patch.ts b/packages/nuxt/src/runtime/server/routes/auth/user/credentials.patch.ts new file mode 100644 index 00000000..3f7bcdca --- /dev/null +++ b/packages/nuxt/src/runtime/server/routes/auth/user/credentials.patch.ts @@ -0,0 +1,64 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {CredentialUpdateErrorResult} from '@thunderid/node'; +import {mapCredentialUpdateError} from '@thunderid/node'; +import {defineEventHandler, readBody, createError} from 'h3'; +import type {H3Event} from 'h3'; +import ThunderIDNuxtClient from '../../../ThunderIDNuxtClient'; +import {verifyAndRehydrateSession} from '../../../utils/serverSession'; +import {useRuntimeConfig} from '#imports'; + +export interface UpdateUserCredentialsResult extends CredentialUpdateErrorResult { + success: boolean; +} + +interface UpdateUserCredentialsBody { + payload: Record; +} + +/** + * PATCH /api/auth/user/credentials + * + * Updates one of the signed-in user's own credentials (e.g. `password`). Mirrors the + * `updateUserCredentialsAction` Next.js server action. + * + * Request body: `{ payload: Record }` — `payload` is keyed by credential attribute + * (e.g. `{ password: "n3wP@ssword!" }`), the same shape `updateMeCredentials`'s own `payload` takes. + * + * Response: `{ success: boolean; field: 'newPassword' | null; message?: string; messageKey: string }`. + * Always resolves 200, even on a failed credential write — the failure is carried in the body + * (`success: false` plus `field`/`message`/`messageKey`, from {@link mapCredentialUpdateError}) + * rather than the HTTP status, so ``'s client component can attach the + * error to the right form field without needing to parse a thrown H3 error. A `401` here means + * the caller's own session is invalid, not that the new credential was rejected — that one + * still throws, same as every other route in this file. + */ +export default defineEventHandler(async (event: H3Event): Promise => { + 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.'}); + } + + let body: UpdateUserCredentialsBody; + try { + body = await readBody(event); + } catch { + throw createError({statusCode: 400, statusMessage: 'Invalid request body.'}); + } + + try { + const client: ThunderIDNuxtClient = ThunderIDNuxtClient.getInstance(); + await client.updateUserCredentials(body.payload, session.sessionId); + return {field: null, messageKey: '', success: true}; + } catch (error) { + const {field, message, messageKey}: CredentialUpdateErrorResult = mapCredentialUpdateError(error); + return {field, message, messageKey, success: false}; + } +}); diff --git a/packages/nuxt/tests/unit/thunderid-ssr.test.ts b/packages/nuxt/tests/unit/thunderid-ssr.test.ts index 080bb4ac..5635afe4 100644 --- a/packages/nuxt/tests/unit/thunderid-ssr.test.ts +++ b/packages/nuxt/tests/unit/thunderid-ssr.test.ts @@ -168,13 +168,13 @@ describe('thunderid-ssr Nitro plugin', () => { it('sets isSignedIn:false when no session cookie is present', async () => { const event = await callHandler('/'); - expect(event.context.thunderid).toEqual({session: null, isSignedIn: false}); + expect(event.context.thunderid).toEqual({flowMeta: null, isSignedIn: false, session: null}); }); it('sets isSignedIn:false when session token verification fails', async () => { vi.mocked(verifySessionToken).mockRejectedValueOnce(new Error('invalid token')); const event = await callHandler('/', 'bad-token-value'); - expect(event.context.thunderid).toEqual({session: null, isSignedIn: false}); + expect(event.context.thunderid).toEqual({flowMeta: null, isSignedIn: false, session: null}); }); // ── Authenticated — full SSR data ──────────────────────────────────────── diff --git a/packages/react/src/contexts/Theme/ThemeProvider.tsx b/packages/react/src/contexts/Theme/ThemeProvider.tsx index 3604c649..44a2b995 100644 --- a/packages/react/src/contexts/Theme/ThemeProvider.tsx +++ b/packages/react/src/contexts/Theme/ThemeProvider.tsx @@ -1,7 +1,17 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {createTheme, Theme, ThemeConfig, ThemeMode, RecursivePartial, FlowMetaTheme} from '@thunderid/browser'; +import { + createClassObserver, + createMediaQueryListener, + createTheme, + detectThemeMode, + FlowMetaTheme, + RecursivePartial, + Theme, + ThemeConfig, + ThemeMode, +} from '@thunderid/browser'; import {FC, PropsWithChildren, ReactElement, useCallback, useContext, useEffect, useMemo, useState} from 'react'; import ThemeContext from './ThemeContext'; import applyThemeToDOM from '../../utils/applyThemeToDOM'; @@ -53,6 +63,7 @@ export interface ThemeProviderProps { */ const ThemeProvider: FC> = ({ children, + mode, theme: themeOverrideProp, }: PropsWithChildren): ReactElement => { const themeOverride: RecursivePartial | undefined = normalizeThemeConfig(themeOverrideProp); @@ -62,15 +73,47 @@ const ThemeProvider: FC> = ({ const isLoading: boolean = flowMetaContext?.isLoading ?? false; const error: Error | null = flowMetaContext?.error ?? null; - // Seed the color scheme from the server's defaultColorScheme; allow local toggling. - const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(() => flowMetaTheme?.defaultColorScheme ?? 'light'); + // Seed the color scheme: a fixed mode wins outright, 'system'/'class' detect it from the + // environment via the same shared utilities Vue's ThemeProvider uses, and no mode falls back + // to the server's default (unchanged pre-`mode` behavior). + const getInitialColorScheme = (): 'light' | 'dark' => { + if (mode === 'light' || mode === 'dark') return mode; + if (mode === 'system' || mode === 'class') return detectThemeMode(mode); + return flowMetaTheme?.defaultColorScheme ?? 'light'; + }; + + const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(getInitialColorScheme); - // When meta finishes loading, sync the color scheme with the server default. + // When mode is unset, sync the color scheme with the server default once flow meta loads. useEffect(() => { - if (flowMetaTheme?.defaultColorScheme) { + if (mode === undefined && flowMetaTheme?.defaultColorScheme) { setColorScheme(flowMetaTheme.defaultColorScheme); } - }, [flowMetaTheme?.defaultColorScheme]); + }, [mode, flowMetaTheme?.defaultColorScheme]); + + // Set up automatic detection listeners for 'system'/'class' modes. + useEffect(() => { + if (mode !== 'system' && mode !== 'class') return undefined; + + const handleThemeChange = (isDark: boolean): void => { + setColorScheme(isDark ? 'dark' : 'light'); + }; + + if (mode === 'class') { + if (typeof document === 'undefined') return undefined; + const observer = createClassObserver(document.documentElement, handleThemeChange); + return () => { + observer.disconnect(); + }; + } + + const mediaQuery = createMediaQueryListener(handleThemeChange); + return () => { + if (mediaQuery?.removeEventListener) { + mediaQuery.removeEventListener('change', handleThemeChange as unknown as EventListener); + } + }; + }, [mode]); const toggleTheme: () => void = useCallback(() => { setColorScheme((prev: 'light' | 'dark') => (prev === 'light' ? 'dark' : 'light')); diff --git a/samples/express/quickstart/README.md b/samples/express/quickstart/README.md index df8b1975..fb6cd027 100644 --- a/samples/express/quickstart/README.md +++ b/samples/express/quickstart/README.md @@ -43,7 +43,7 @@ Open [http://localhost:3000](http://localhost:3000) for the API documentation pa ## Trying the API - `GET /api/public` — no auth required. -- `GET /api/protected` and `GET /api/me` — require `Authorization: Bearer `. +- `GET /api/protected`, `GET /api/me`, and `PATCH /api/me/credentials` — require `Authorization: Bearer `. To get a token to test with, open [http://localhost:3000/login](http://localhost:3000/login), sign in once, and your access token (decoded claims included) will be shown at diff --git a/samples/express/quickstart/index.mjs b/samples/express/quickstart/index.mjs index 7b8a0fb0..b94cc0a4 100644 --- a/samples/express/quickstart/index.mjs +++ b/samples/express/quickstart/index.mjs @@ -2,7 +2,7 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; import express from 'express'; import cookieParser from 'cookie-parser'; -import {thunderID, handleSignIn, handleSignOut, protect} from '@thunderid/express'; +import {thunderID, handleSignIn, handleSignOut, protect, updateMeCredentials} from '@thunderid/express'; import {verifyBearerToken} from './lib/auth.mjs'; import {layout, esc, escAttr, COPY_ICON} from './lib/layout.mjs'; import {thunderMark} from './lib/thunderMark.mjs'; @@ -232,6 +232,19 @@ app.get('/', async (req, res) => { curl: ['curl http://localhost:3000/api/me \\', ` -H "Authorization: ${bearerLine(accessToken)}"`], sample: JSON.stringify({sub: '...', email: 'jane@example.com', given_name: 'Jane'}, null, 2), })} + ${endpoint({ + method: 'PATCH', + path: '/api/me/credentials', + summary: 'Updates credentials (such as password) for the authenticated caller.', + protectedRoute: true, + curl: [ + 'curl -X PATCH http://localhost:3000/api/me/credentials \\', + ` -H "Authorization: ${bearerLine(accessToken)}" \\`, + ' -H "Content-Type: application/json" \\', + ' -d \'{"password": "n3wP@ssword!"}\'', + ], + sample: '204 No Content', + })}
@@ -347,6 +360,24 @@ app.get('/api/me', requireBearer, (req, res) => { res.json(req.thunderIDUserInfo); }); +app.patch('/api/me/credentials', requireBearer, async (req, res) => { + try { + const authHeader = req.headers.authorization; + const token = authHeader?.split(' ')[1]; + await updateMeCredentials({ + baseUrl, + headers: {Authorization: `Bearer ${token}`}, + payload: req.body, + }); + res.status(204).end(); + } catch (error) { + res.status(error?.statusCode || 500).json({ + error: error?.code || 'CREDENTIAL_UPDATE_FAILED', + message: error?.message || 'Failed to update credentials', + }); + } +}); + // ── Postman collection download ───────────────────────────────────────── app.get('/postman-collection.json', (_req, res) => { diff --git a/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json b/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json index 7e0b995b..d2acf645 100644 --- a/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json +++ b/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json @@ -34,6 +34,21 @@ "url": {"raw": "{{baseUrl}}/api/me", "host": ["{{baseUrl}}"], "path": ["api", "me"]} } }, + { + "name": "Update credentials", + "request": { + "method": "PATCH", + "header": [ + {"key": "Authorization", "value": "Bearer {{accessToken}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"password\": \"n3wP@ssword!\"\n}" + }, + "url": {"raw": "{{baseUrl}}/api/me/credentials", "host": ["{{baseUrl}}"], "path": ["api", "me", "credentials"]} + } + }, { "name": "ThunderID userinfo (direct)", "request": { diff --git a/samples/nextjs/quickstart/app/account/page.tsx b/samples/nextjs/quickstart/app/account/page.tsx new file mode 100644 index 00000000..d0e17a40 --- /dev/null +++ b/samples/nextjs/quickstart/app/account/page.tsx @@ -0,0 +1,212 @@ +'use client' +import { ChangeCredential, SignedIn, SignedOut, User, UserProfile } from '@thunderid/nextjs' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { useState } from 'react' +import Nav from '../components/Nav' + +function HomeIcon() { + return ( + + ) +} + +function PersonIcon() { + return ( + + ) +} + +function ShieldIcon() { + return ( + + ) +} + +const TABS = [ + { id: 'home', label: 'Home', icon: HomeIcon }, + { id: 'personal', label: 'Personal info', icon: PersonIcon }, + { id: 'security', label: 'Security', icon: ShieldIcon }, +] + +const CREDENTIALS = [ + { attribute: 'password', title: 'Password', description: 'Used to sign in to your account.', cta: 'Change password' }, +] + +function ChevronIcon() { + return ( + + + + ) +} + +function CredentialCard({ attribute, title, description, cta }: { attribute: string; title: string; description: string; cta: string }) { + const [open, setOpen] = useState(false) + const close = () => setOpen(false) + + return ( +
+
+
+
{title}
+
{description}
+
+ +
+ {open && ( +
+ +
+ )} +
+ ) +} + +const TILES = [ + { + id: 'personal', + icon: PersonIcon, + tone: 'blue', + title: 'Personal info', + description: 'Name, email, phone, and profile photo', + }, + { + id: 'security', + icon: ShieldIcon, + tone: 'green', + title: 'Security', + description: 'Password and other credentials', + }, +] + +// Google Account-style layout: a persistent left nav switching between tabs of +// content on the right, rather than a single scrolling page or a modal. +function AccountTabs() { + const router = useRouter() + const searchParams = useSearchParams() + const requestedTab = searchParams.get('tab') + const [tab, setTab] = useState(TABS.some((t) => t.id === requestedTab) ? requestedTab! : 'home') + + const selectTab = (id: string) => { + setTab(id) + router.replace(id === 'home' ? '/account' : `/account?tab=${id}`) + } + + return ( +
+ + +
+
+ {tab === 'home' && ( + + {(user: { givenName?: string; given_name?: string; displayName?: string; username?: string } | null) => { + const givenName = user?.givenName ?? user?.given_name ?? user?.displayName ?? user?.username ?? 'there' + + return ( + <> +

Hi, {givenName}

+

+ Manage your info and security across ThunderID apps. +

+
+ {TILES.map(({ id, icon: Icon, tone, title, description }) => ( + + ))} +
+ + ) + }} +
+ )} + + {tab === 'personal' && ( + <> +

Personal info

+

Manage the basic profile info others may see.

+ + + )} + + {tab === 'security' && ( + <> +

Security

+

Manage your password and other credentials.

+
+ {CREDENTIALS.map((credential) => ( + + ))} +
+ + )} +
+
+
+ ) +} + +export default function AccountPage() { + return ( +
+
+ ) +} diff --git a/samples/nextjs/quickstart/app/components/Nav.tsx b/samples/nextjs/quickstart/app/components/Nav.tsx index a837587f..e660f18e 100644 --- a/samples/nextjs/quickstart/app/components/Nav.tsx +++ b/samples/nextjs/quickstart/app/components/Nav.tsx @@ -1,7 +1,7 @@ 'use client' -import { usePathname } from 'next/navigation' -import Link from 'next/link' import { SignedIn, SignedOut, SignInButton, UserDropdown } from '@thunderid/nextjs' +import Link from 'next/link' +import { usePathname, useRouter } from 'next/navigation' import { useState } from 'react' import NextLogo from './icons/NextLogo' @@ -46,6 +46,7 @@ function KeyIcon() { export default function Nav() { const [dark, setDark] = useState(false) const pathname = usePathname() + const router = useRouter() const isHome = pathname === '/' const toggle = () => { @@ -79,7 +80,12 @@ export default function Nav() { {dark ? : } - + router.push('/account')} + /> diff --git a/samples/nextjs/quickstart/app/globals.css b/samples/nextjs/quickstart/app/globals.css index 918b551c..6972b479 100644 --- a/samples/nextjs/quickstart/app/globals.css +++ b/samples/nextjs/quickstart/app/globals.css @@ -37,6 +37,22 @@ html.dark { --color-text-muted: rgba(224, 234, 255, 0.48); --color-muted: rgba(224, 234, 255, 0.48); --color-nav-bg: rgba(8, 15, 28, 0.92); + + /* ThunderID Design Tokens for Dark Mode */ + --thunderid-color-background-surface: #0f1a2d; + --thunderid-color-background-body: #080f1c; + --thunderid-color-background-disabled: rgba(255, 255, 255, 0.06); + --thunderid-color-background-muted: rgba(255, 255, 255, 0.05); + --thunderid-color-text-primary: #E0EAFF; + --thunderid-color-text-secondary: rgba(224, 234, 255, 0.65); + --thunderid-color-border: rgba(255, 255, 255, 0.09); + --thunderid-color-border-focus: var(--color-primary); + --thunderid-input-borderColor: rgba(255, 255, 255, 0.16); + --thunderid-input-focusBorderColor: var(--color-primary); + --thunderid-input-focusRing: 0 0 0 3px rgba(54, 136, 255, 0.25); + --thunderid-card-borderColor: rgba(255, 255, 255, 0.09); + --thunderid-card-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + --thunderid-color-action-hover: rgba(255, 255, 255, 0.06); } /* ── Base ── */ @@ -1055,3 +1071,451 @@ button:focus-visible { padding: 24px 16px; } } + +/* ─── Account page (Google Account-style left-nav layout) ─────────── */ +.account-page { + display: flex; + align-items: flex-start; + min-height: calc(100vh - 52px); + margin-top: 52px; +} + +.account-sidebar { + width: 280px; + flex-shrink: 0; + padding: 40px 24px; + position: sticky; + top: 52px; +} + +.account-sidebar-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); + margin: 0 0 24px 16px; +} + +.account-nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.account-nav-item { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + padding: 11px 16px; + border: none; + border-radius: 20px; + background: transparent; + color: var(--color-muted); + font-size: 14px; + font-weight: 500; + font-family: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; + transition: background 0.2s, color 0.2s; +} + +.account-nav-item:hover { + background: var(--color-border); + color: var(--color-text); +} + +.account-nav-item--active, +.account-nav-item--active:hover { + background: rgba(54, 136, 255, 0.12); + color: var(--color-primary); +} + +.account-content { + flex: 1; + min-width: 0; + display: flex; + justify-content: center; + padding: 40px 32px 80px; + border-left: 1px solid var(--color-border); +} + +.account-content-inner { + width: 100%; + max-width: 720px; +} + +/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting + cramped in other contexts) fights this page's own responsive layout, refusing to shrink + below 600px even when the viewport is narrower. The Security/Home boxes have no such floor + and already shrink fluidly with their container, so match that here. */ +.account-content .thunderid-user-profile { + min-width: 0; + background: var(--color-card) !important; + border: 1px solid var(--color-border) !important; + border-radius: var(--radius-card) !important; + box-shadow: var(--shadow-card); + color: var(--color-text); + box-sizing: border-box; +} + +.account-content .thunderid-user-profile .thunderid-user-profile__hero { + border-bottom: 1px solid var(--color-border); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__hero-name { + color: var(--color-text); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__hero-subtitle { + color: var(--color-text-muted); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field { + border-bottom: 1px solid var(--color-border); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-label { + color: var(--color-text-muted); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-value { + color: var(--color-text); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-placeholder { + color: var(--color-text-muted); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn { + color: var(--color-text-muted); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn:hover { + color: var(--color-primary); +} + +.account-content .thunderid-user-profile input, +.account-content .thunderid-user-profile select, +.account-content .thunderid-user-profile textarea { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--color-border); + border-radius: var(--radius-btn); + color: var(--color-text); + padding: 6px 10px; +} + +.account-content-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); + margin: 0 0 6px; +} + +.account-content-subtitle { + font-size: 14px; + color: var(--color-muted); + margin: 0 0 32px; +} + +.account-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 8px; +} + +.account-tile { + text-align: left; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 22px; + cursor: pointer; + font-family: inherit; + display: flex; + flex-direction: column; + transition: border-color 0.2s; +} + +.account-tile:hover { + border-color: rgba(54, 136, 255, 0.35); +} + +.account-tile-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; +} + +.account-tile-icon--blue { + background: rgba(54, 136, 255, 0.12); + color: var(--color-primary); +} + +.account-tile-icon--green { + background: rgba(16, 185, 129, 0.12); + color: #10b981; +} + +.account-tile-title { + font-size: 15px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 4px; +} + +.account-tile-desc { + font-size: 12.5px; + color: var(--color-muted); + line-height: 1.5; +} + +.account-security-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.account-security-card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 20px 22px; +} + +.account-security-card-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.account-security-card-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 2px; +} + +.account-security-card-desc { + font-size: 12.5px; + color: var(--color-muted); +} + +.account-security-card-cta { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0; + background: none; + border: none; + color: var(--color-primary); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: opacity 0.2s; +} + +.account-security-card-cta:hover { + opacity: 0.8; +} + +.account-security-card-chevron { + display: inline-flex; + transition: transform 0.2s; +} + +.account-security-card-chevron--open { + transform: rotate(180deg); +} + +.account-security-card-form { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--color-border); +} + +@media (max-width: 760px) { + .account-page { + flex-direction: column; + } + .account-sidebar { + width: 100%; + position: static; + padding: 20px 16px 8px; + } + .account-nav { + flex-direction: row; + overflow-x: auto; + } + .account-content { + max-width: 100%; + border-left: none; + border-top: 1px solid var(--color-border); + padding: 24px 20px 60px; + } +} + +/* ── ThunderID inputs & forms dark mode ── */ +.dark .thunderid-password-field__wrapper, +html.dark .thunderid-password-field__wrapper, +.app.dark .thunderid-password-field__wrapper, +.dark .thunderid-text-field__wrapper, +html.dark .thunderid-text-field__wrapper, +.app.dark .thunderid-text-field__wrapper, +.dark .thunderid-text-field__input, +html.dark .thunderid-text-field__input, +.app.dark .thunderid-text-field__input { + background-color: rgba(255, 255, 255, 0.05) !important; + border-color: rgba(255, 255, 255, 0.16) !important; + color: var(--color-text) !important; +} + +.dark .thunderid-password-field__label, +html.dark .thunderid-password-field__label, +.app.dark .thunderid-password-field__label, +.dark .thunderid-text-field__label, +html.dark .thunderid-text-field__label, +.app.dark .thunderid-text-field__label, +.dark .thunderid-input-label, +html.dark .thunderid-input-label, +.app.dark .thunderid-input-label { + color: var(--color-text) !important; +} + +.dark .thunderid-password-field__input, +html.dark .thunderid-password-field__input, +.app.dark .thunderid-password-field__input { + background: transparent !important; + color: var(--color-text) !important; +} + +.dark .thunderid-password-field__input::placeholder, +html.dark .thunderid-password-field__input::placeholder, +.app.dark .thunderid-password-field__input::placeholder, +.dark .thunderid-text-field__input::placeholder, +html.dark .thunderid-text-field__input::placeholder, +.app.dark .thunderid-text-field__input::placeholder { + color: var(--color-text-muted) !important; +} + +.dark .thunderid-password-field__toggle, +html.dark .thunderid-password-field__toggle, +.app.dark .thunderid-password-field__toggle { + color: var(--color-text-muted) !important; + background: transparent !important; +} + +.dark .thunderid-password-field__toggle:hover, +html.dark .thunderid-password-field__toggle:hover, +.app.dark .thunderid-password-field__toggle:hover { + color: var(--color-text) !important; +} + +.dark .thunderid-change-credential__requirements-heading, +html.dark .thunderid-change-credential__requirements-heading, +.app.dark .thunderid-change-credential__requirements-heading, +.dark .thunderid-change-credential__requirement, +html.dark .thunderid-change-credential__requirement, +.app.dark .thunderid-change-credential__requirement { + color: var(--color-text-muted) !important; +} + +.dark .thunderid-change-credential__requirement--passed, +html.dark .thunderid-change-credential__requirement--passed, +.app.dark .thunderid-change-credential__requirement--passed { + color: #38c172 !important; +} + +/* ── ThunderID UserDropdown dark mode ── */ +.dark .thunderid-user-dropdown__menu, +html.dark .thunderid-user-dropdown__menu, +.app.dark .thunderid-user-dropdown__menu, +.dark .thunderid-user-dropdown__content, +html.dark .thunderid-user-dropdown__content, +.app.dark .thunderid-user-dropdown__content { + background-color: var(--color-card) !important; + border: 1px solid var(--color-border) !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45) !important; + color: var(--color-text) !important; +} + +.dark .thunderid-user-dropdown__menu-header, +html.dark .thunderid-user-dropdown__menu-header, +.app.dark .thunderid-user-dropdown__menu-header, +.dark .thunderid-user-dropdown__header, +html.dark .thunderid-user-dropdown__header, +.app.dark .thunderid-user-dropdown__header { + border-bottom: 1px solid var(--color-border) !important; +} + +.dark .thunderid-user-dropdown__menu-header-name, +html.dark .thunderid-user-dropdown__menu-header-name, +.app.dark .thunderid-user-dropdown__menu-header-name, +.dark .thunderid-user-dropdown__header-name, +html.dark .thunderid-user-dropdown__header-name, +.app.dark .thunderid-user-dropdown__header-name { + color: var(--color-text) !important; +} + +.dark .thunderid-user-dropdown__menu-header-subtitle, +html.dark .thunderid-user-dropdown__menu-header-subtitle, +.app.dark .thunderid-user-dropdown__menu-header-subtitle, +.dark .thunderid-user-dropdown__header-subtitle, +html.dark .thunderid-user-dropdown__header-subtitle, +.app.dark .thunderid-user-dropdown__header-subtitle { + color: var(--color-text-muted) !important; +} + +.dark .thunderid-user-dropdown__menu-divider, +html.dark .thunderid-user-dropdown__menu-divider, +.app.dark .thunderid-user-dropdown__menu-divider, +.dark .thunderid-divider, +html.dark .thunderid-divider, +.app.dark .thunderid-divider { + background-color: var(--color-border) !important; + border-color: var(--color-border) !important; +} + +.dark .thunderid-user-dropdown__item, +html.dark .thunderid-user-dropdown__item, +.app.dark .thunderid-user-dropdown__item { + color: var(--color-text) !important; +} + +.dark .thunderid-user-dropdown__item:hover, +html.dark .thunderid-user-dropdown__item:hover, +.app.dark .thunderid-user-dropdown__item:hover { + background-color: rgba(255, 255, 255, 0.06) !important; + color: var(--color-text) !important; +} + +.dark .thunderid-user-dropdown__item--danger, +html.dark .thunderid-user-dropdown__item--danger, +.app.dark .thunderid-user-dropdown__item--danger { + color: #f87171 !important; +} + +.dark .thunderid-user-dropdown__item--danger:hover, +html.dark .thunderid-user-dropdown__item--danger:hover, +.app.dark .thunderid-user-dropdown__item--danger:hover { + background-color: rgba(239, 68, 68, 0.12) !important; +} + +.dark .thunderid-card, +html.dark .thunderid-card, +.app.dark .thunderid-card { + background-color: var(--color-card) !important; + border-color: var(--color-border) !important; + color: var(--color-text) !important; +} + diff --git a/samples/nextjs/quickstart/app/layout.tsx b/samples/nextjs/quickstart/app/layout.tsx index 7340e107..d67dade0 100644 --- a/samples/nextjs/quickstart/app/layout.tsx +++ b/samples/nextjs/quickstart/app/layout.tsx @@ -37,7 +37,7 @@ export default function RootLayout({ {missingEnvVars.length > 0 ? ( ) : ( - + {children} )} diff --git a/samples/nuxt/quickstart/app/assets/styles.css b/samples/nuxt/quickstart/app/assets/styles.css index 8f2aaf68..82719ff9 100644 --- a/samples/nuxt/quickstart/app/assets/styles.css +++ b/samples/nuxt/quickstart/app/assets/styles.css @@ -32,13 +32,31 @@ } /* ── Dark mode ── */ -.app.dark { +.app.dark, +.dark, +html.dark { --color-bg: #080f1c; --color-card: #0f1a2d; --color-border: rgba(255, 255, 255, 0.09); --color-text: #E0EAFF; --color-text-muted: rgba(224, 234, 255, 0.48); --color-nav-bg: rgba(8, 15, 28, 0.92); + + /* ThunderID Design Tokens for Dark Mode */ + --thunderid-color-background-surface: #0f1a2d; + --thunderid-color-background-body: #080f1c; + --thunderid-color-background-disabled: rgba(255, 255, 255, 0.06); + --thunderid-color-background-muted: rgba(255, 255, 255, 0.05); + --thunderid-color-text-primary: #E0EAFF; + --thunderid-color-text-secondary: rgba(224, 234, 255, 0.65); + --thunderid-color-border: rgba(255, 255, 255, 0.09); + --thunderid-color-border-focus: var(--color-primary); + --thunderid-input-borderColor: rgba(255, 255, 255, 0.16); + --thunderid-input-focusBorderColor: var(--color-primary); + --thunderid-input-focusRing: 0 0 0 3px rgba(54, 136, 255, 0.25); + --thunderid-card-borderColor: rgba(255, 255, 255, 0.09); + --thunderid-card-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + --thunderid-color-action-hover: rgba(255, 255, 255, 0.06); } /* ── Navigation ── */ @@ -929,3 +947,451 @@ button:focus-visible { color: var(--color-text); letter-spacing: -0.01em; } + +/* ─── Account page (Google Account-style left-nav layout) ─────────── */ +.account-page { + display: flex; + align-items: flex-start; + min-height: calc(100vh - 52px); + margin-top: 52px; +} + +.account-sidebar { + width: 280px; + flex-shrink: 0; + padding: 40px 24px; + position: sticky; + top: 52px; +} + +.account-sidebar-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); + margin: 0 0 24px 16px; +} + +.account-nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.account-nav-item { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + padding: 11px 16px; + border: none; + border-radius: 20px; + background: transparent; + color: var(--color-text-muted); + font-size: 14px; + font-weight: 500; + font-family: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; + transition: background 0.2s, color 0.2s; +} + +.account-nav-item:hover { + background: var(--color-border); + color: var(--color-text); +} + +.account-nav-item--active, +.account-nav-item--active:hover { + background: rgba(54, 136, 255, 0.12); + color: var(--color-primary); +} + +.account-content { + flex: 1; + min-width: 0; + display: flex; + justify-content: center; + padding: 40px 32px 80px; + border-left: 1px solid var(--color-border); +} + +.account-content-inner { + width: 100%; + max-width: 720px; +} + +/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting + cramped in other contexts) fights this page's own responsive layout, refusing to shrink + below 600px even when the viewport is narrower. The Security/Home boxes have no such floor + and already shrink fluidly with their container, so match that here. */ +.account-content .thunderid-user-profile { + min-width: 0; + background: var(--color-card) !important; + border: 1px solid var(--color-border) !important; + border-radius: var(--radius-card) !important; + box-shadow: var(--shadow-card); + color: var(--color-text); + box-sizing: border-box; +} + +.account-content .thunderid-user-profile .thunderid-user-profile__hero { + border-bottom: 1px solid var(--color-border); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__hero-name { + color: var(--color-text); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__hero-subtitle { + color: var(--color-text-muted); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field { + border-bottom: 1px solid var(--color-border); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-label { + color: var(--color-text-muted); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-value { + color: var(--color-text); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-placeholder { + color: var(--color-text-muted); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn { + color: var(--color-text-muted); +} + +.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn:hover { + color: var(--color-primary); +} + +.account-content .thunderid-user-profile input, +.account-content .thunderid-user-profile select, +.account-content .thunderid-user-profile textarea { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--color-border); + border-radius: var(--radius-btn); + color: var(--color-text); + padding: 6px 10px; +} + +.account-content-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); + margin: 0 0 6px; +} + +.account-content-subtitle { + font-size: 14px; + color: var(--color-text-muted); + margin: 0 0 32px; +} + +.account-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 8px; +} + +.account-tile { + text-align: left; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 22px; + cursor: pointer; + font-family: inherit; + display: flex; + flex-direction: column; + transition: border-color 0.2s; +} + +.account-tile:hover { + border-color: rgba(54, 136, 255, 0.35); +} + +.account-tile-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; +} + +.account-tile-icon--blue { + background: rgba(54, 136, 255, 0.12); + color: var(--color-primary); +} + +.account-tile-icon--green { + background: rgba(16, 185, 129, 0.12); + color: #10b981; +} + +.account-tile-title { + font-size: 15px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 4px; +} + +.account-tile-desc { + font-size: 12.5px; + color: var(--color-text-muted); + line-height: 1.5; +} + +.account-security-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.account-security-card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 20px 22px; +} + +.account-security-card-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.account-security-card-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 2px; +} + +.account-security-card-desc { + font-size: 12.5px; + color: var(--color-text-muted); +} + +.account-security-card-cta { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0; + background: none; + border: none; + color: var(--color-primary); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: opacity 0.2s; +} + +.account-security-card-cta:hover { + opacity: 0.8; +} + +.account-security-card-chevron { + display: inline-flex; + transition: transform 0.2s; +} + +.account-security-card-chevron--open { + transform: rotate(180deg); +} + +.account-security-card-form { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--color-border); +} + +@media (max-width: 760px) { + .account-page { + flex-direction: column; + } + .account-sidebar { + width: 100%; + position: static; + padding: 20px 16px 8px; + } + .account-nav { + flex-direction: row; + overflow-x: auto; + } + .account-content { + max-width: 100%; + border-left: none; + border-top: 1px solid var(--color-border); + padding: 24px 20px 60px; + } +} + +/* ── ThunderID inputs & forms dark mode ── */ +.dark .thunderid-password-field__wrapper, +html.dark .thunderid-password-field__wrapper, +.app.dark .thunderid-password-field__wrapper, +.dark .thunderid-text-field__wrapper, +html.dark .thunderid-text-field__wrapper, +.app.dark .thunderid-text-field__wrapper, +.dark .thunderid-text-field__input, +html.dark .thunderid-text-field__input, +.app.dark .thunderid-text-field__input { + background-color: rgba(255, 255, 255, 0.05) !important; + border-color: rgba(255, 255, 255, 0.16) !important; + color: var(--color-text) !important; +} + +.dark .thunderid-password-field__label, +html.dark .thunderid-password-field__label, +.app.dark .thunderid-password-field__label, +.dark .thunderid-text-field__label, +html.dark .thunderid-text-field__label, +.app.dark .thunderid-text-field__label, +.dark .thunderid-input-label, +html.dark .thunderid-input-label, +.app.dark .thunderid-input-label { + color: var(--color-text) !important; +} + +.dark .thunderid-password-field__input, +html.dark .thunderid-password-field__input, +.app.dark .thunderid-password-field__input { + background: transparent !important; + color: var(--color-text) !important; +} + +.dark .thunderid-password-field__input::placeholder, +html.dark .thunderid-password-field__input::placeholder, +.app.dark .thunderid-password-field__input::placeholder, +.dark .thunderid-text-field__input::placeholder, +html.dark .thunderid-text-field__input::placeholder, +.app.dark .thunderid-text-field__input::placeholder { + color: var(--color-text-muted) !important; +} + +.dark .thunderid-password-field__toggle, +html.dark .thunderid-password-field__toggle, +.app.dark .thunderid-password-field__toggle { + color: var(--color-text-muted) !important; + background: transparent !important; +} + +.dark .thunderid-password-field__toggle:hover, +html.dark .thunderid-password-field__toggle:hover, +.app.dark .thunderid-password-field__toggle:hover { + color: var(--color-text) !important; +} + +.dark .thunderid-change-credential__requirements-heading, +html.dark .thunderid-change-credential__requirements-heading, +.app.dark .thunderid-change-credential__requirements-heading, +.dark .thunderid-change-credential__requirement, +html.dark .thunderid-change-credential__requirement, +.app.dark .thunderid-change-credential__requirement { + color: var(--color-text-muted) !important; +} + +.dark .thunderid-change-credential__requirement--passed, +html.dark .thunderid-change-credential__requirement--passed, +.app.dark .thunderid-change-credential__requirement--passed { + color: #38c172 !important; +} + +/* ── ThunderID UserDropdown dark mode ── */ +.dark .thunderid-user-dropdown__menu, +html.dark .thunderid-user-dropdown__menu, +.app.dark .thunderid-user-dropdown__menu, +.dark .thunderid-user-dropdown__content, +html.dark .thunderid-user-dropdown__content, +.app.dark .thunderid-user-dropdown__content { + background-color: var(--color-card) !important; + border: 1px solid var(--color-border) !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45) !important; + color: var(--color-text) !important; +} + +.dark .thunderid-user-dropdown__menu-header, +html.dark .thunderid-user-dropdown__menu-header, +.app.dark .thunderid-user-dropdown__menu-header, +.dark .thunderid-user-dropdown__header, +html.dark .thunderid-user-dropdown__header, +.app.dark .thunderid-user-dropdown__header { + border-bottom: 1px solid var(--color-border) !important; +} + +.dark .thunderid-user-dropdown__menu-header-name, +html.dark .thunderid-user-dropdown__menu-header-name, +.app.dark .thunderid-user-dropdown__menu-header-name, +.dark .thunderid-user-dropdown__header-name, +html.dark .thunderid-user-dropdown__header-name, +.app.dark .thunderid-user-dropdown__header-name { + color: var(--color-text) !important; +} + +.dark .thunderid-user-dropdown__menu-header-subtitle, +html.dark .thunderid-user-dropdown__menu-header-subtitle, +.app.dark .thunderid-user-dropdown__menu-header-subtitle, +.dark .thunderid-user-dropdown__header-subtitle, +html.dark .thunderid-user-dropdown__header-subtitle, +.app.dark .thunderid-user-dropdown__header-subtitle { + color: var(--color-text-muted) !important; +} + +.dark .thunderid-user-dropdown__menu-divider, +html.dark .thunderid-user-dropdown__menu-divider, +.app.dark .thunderid-user-dropdown__menu-divider, +.dark .thunderid-divider, +html.dark .thunderid-divider, +.app.dark .thunderid-divider { + background-color: var(--color-border) !important; + border-color: var(--color-border) !important; +} + +.dark .thunderid-user-dropdown__item, +html.dark .thunderid-user-dropdown__item, +.app.dark .thunderid-user-dropdown__item { + color: var(--color-text) !important; +} + +.dark .thunderid-user-dropdown__item:hover, +html.dark .thunderid-user-dropdown__item:hover, +.app.dark .thunderid-user-dropdown__item:hover { + background-color: rgba(255, 255, 255, 0.06) !important; + color: var(--color-text) !important; +} + +.dark .thunderid-user-dropdown__item--danger, +html.dark .thunderid-user-dropdown__item--danger, +.app.dark .thunderid-user-dropdown__item--danger { + color: #f87171 !important; +} + +.dark .thunderid-user-dropdown__item--danger:hover, +html.dark .thunderid-user-dropdown__item--danger:hover, +.app.dark .thunderid-user-dropdown__item--danger:hover { + background-color: rgba(239, 68, 68, 0.12) !important; +} + +.dark .thunderid-card, +html.dark .thunderid-card, +.app.dark .thunderid-card { + background-color: var(--color-card) !important; + border-color: var(--color-border) !important; + color: var(--color-text) !important; +} + diff --git a/samples/nuxt/quickstart/app/components/AppNav.vue b/samples/nuxt/quickstart/app/components/AppNav.vue index d785170f..91ac758c 100644 --- a/samples/nuxt/quickstart/app/components/AppNav.vue +++ b/samples/nuxt/quickstart/app/components/AppNav.vue @@ -10,7 +10,22 @@ const menuItems = [ function toggleDark() { dark.value = !dark.value + if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('dark', dark.value) + } } + +onMounted(() => { + if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('dark', dark.value) + } +}) + +watch(dark, (isDark) => { + if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('dark', isDark) + } +})