From 057cc869b0e0b89c81c5d4904e75c45fba1da298 Mon Sep 17 00:00:00 2001 From: Brion Date: Mon, 17 Aug 2026 21:56:12 +0530 Subject: [PATCH] Fix `flow/meta` in SSR SDKs + Nuxt SDK fixes Signed-off-by: Brion --- packages/javascript/src/i18n/models/i18n.ts | 1 + .../javascript/src/i18n/translations/en-US.ts | 1 + .../javascript/src/i18n/translations/fr-FR.ts | 1 + .../javascript/src/i18n/translations/hi-IN.ts | 1 + .../javascript/src/i18n/translations/ja-JP.ts | 1 + .../javascript/src/i18n/translations/pt-BR.ts | 1 + .../javascript/src/i18n/translations/pt-PT.ts | 1 + .../javascript/src/i18n/translations/si-LK.ts | 1 + .../javascript/src/i18n/translations/ta-IN.ts | 1 + .../javascript/src/i18n/translations/te-IN.ts | 1 + .../contexts/ThunderID/ThunderIDProvider.tsx | 15 +- .../nextjs/src/server/ThunderIDProvider.tsx | 2 + .../src/server/actions/getFlowMetaAction.ts | 44 ++ packages/nuxt/src/module.ts | 45 +- .../src/runtime/components/ThunderIDRoot.ts | 33 +- .../components/actions/SignInButton.ts | 4 +- .../components/actions/SignOutButton.ts | 4 +- .../components/actions/SignUpButton.ts | 4 +- .../src/runtime/components/auth/Callback.ts | 2 +- .../src/runtime/components/auth/SignIn.ts | 702 ++++++++++++++++-- .../src/runtime/components/auth/SignUp.ts | 2 +- .../src/runtime/components/control/Loading.ts | 4 +- .../runtime/components/control/SignedIn.ts | 4 +- .../runtime/components/control/SignedOut.ts | 4 +- .../nuxt/src/runtime/components/user/User.ts | 4 +- .../runtime/components/user/UserDropdown.ts | 2 +- .../runtime/components/user/UserProfile.ts | 2 +- .../src/runtime/composables/useThunderID.ts | 25 +- .../nuxt/src/runtime/plugins/thunderid.ts | 27 +- .../src/runtime/server/ThunderIDNuxtClient.ts | 29 +- .../runtime/server/plugins/thunderid-ssr.ts | 41 +- .../routes/auth/session/callback.post.ts | 2 +- .../server/routes/auth/session/meta.get.ts | 36 + .../server/routes/auth/session/signin.post.ts | 60 +- packages/nuxt/src/runtime/types.ts | 7 + packages/nuxt/src/runtime/utils/stateKeys.ts | 7 + packages/nuxt/tests/unit/signin-post.test.ts | 27 +- .../nuxt/tests/unit/thunderid-root.test.ts | 6 +- .../presentation/auth/AuthOptionFactory.tsx | 13 +- .../contexts/FlowMeta/FlowMetaProvider.tsx | 44 +- .../src/utils/resolveTranslationsInObject.ts | 2 +- .../auth/sign-in/AuthOptionFactoryCore.ts | 96 ++- .../components/auth/sign-in/BaseSignIn.css.ts | 50 ++ .../src/components/auth/sign-in/BaseSignIn.ts | 57 +- .../vue/src/components/auth/sign-in/SignIn.ts | 38 + .../src/components/factories/FieldFactory.ts | 8 + .../invite-user/BaseInviteUser.ts | 2 +- .../language-switcher/LanguageSwitcher.css.ts | 52 +- .../user-dropdown/UserDropdown.css.ts | 146 ++-- .../user-profile/UserProfile.css.ts | 93 ++- .../components/primitives/Alert/Alert.css.ts | 44 +- .../primitives/Button/Button.css.ts | 114 +-- .../components/primitives/Card/Card.css.ts | 12 +- .../primitives/Checkbox/Checkbox.css.ts | 28 +- .../primitives/Checkbox/Checkbox.ts | 2 +- .../primitives/DatePicker/DatePicker.css.ts | 50 +- .../primitives/DatePicker/DatePicker.ts | 2 +- .../primitives/Divider/Divider.css.ts | 26 +- .../components/primitives/Logo/Logo.css.ts | 2 +- .../primitives/OtpField/OtpField.css.ts | 60 +- .../primitives/OtpField/OtpField.ts | 26 + .../PasswordField/PasswordField.css.ts | 58 +- .../primitives/Select/Select.css.ts | 60 +- .../components/primitives/Select/Select.ts | 2 +- .../primitives/Spinner/Spinner.css.ts | 14 +- .../primitives/TextField/TextField.css.ts | 58 +- .../primitives/Typography/Typography.css.ts | 78 +- .../vue/src/composables/useOAuthCallback.ts | 7 + packages/vue/src/index.ts | 3 + .../vue/src/providers/FlowMetaProvider.ts | 50 +- packages/vue/src/providers/ThemeProvider.ts | 64 +- packages/vue/src/styles/defaults.css.ts | 202 ++--- packages/vue/src/styles/injectStyles.ts | 5 + .../src/utils/resolveTranslationsInObject.ts | 2 +- samples/nextjs/quickstart/.env.example | 3 + samples/nextjs/quickstart/app/layout.tsx | 5 +- samples/nuxt/quickstart/.env.example | 11 +- samples/nuxt/quickstart/README.md | 14 +- .../nuxt/quickstart/app/components/AppNav.vue | 14 +- samples/nuxt/quickstart/app/pages/index.vue | 12 +- samples/nuxt/quickstart/app/pages/signin.vue | 10 +- samples/nuxt/quickstart/app/pages/signup.vue | 10 +- .../nuxt/quickstart/scripts/prepare-dev.cjs | 3 +- 83 files changed, 1989 insertions(+), 817 deletions(-) create mode 100644 packages/nextjs/src/server/actions/getFlowMetaAction.ts create mode 100644 packages/nuxt/src/runtime/server/routes/auth/session/meta.get.ts create mode 100644 packages/vue/src/components/auth/sign-in/BaseSignIn.css.ts diff --git a/packages/javascript/src/i18n/models/i18n.ts b/packages/javascript/src/i18n/models/i18n.ts index bde69a62..4684b094 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -59,6 +59,7 @@ export interface I18nTranslations { /* Base Sign In */ 'signin.heading': string; 'signin.subheading': string; + 'signin.images.app_logo.alt': string; /* Base Sign Up */ 'signup.heading': string; diff --git a/packages/javascript/src/i18n/translations/en-US.ts b/packages/javascript/src/i18n/translations/en-US.ts index 408c9012..ef9ba263 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -59,6 +59,7 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.heading': 'Sign In', 'signin.subheading': 'Welcome back! Please sign in to continue.', + 'signin.images.app_logo.alt': 'Application logo', /* Base Sign Up */ 'signup.heading': 'Sign Up', diff --git a/packages/javascript/src/i18n/translations/fr-FR.ts b/packages/javascript/src/i18n/translations/fr-FR.ts index ceb8ccc7..6296bf29 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -59,6 +59,7 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.heading': 'Se connecter', 'signin.subheading': 'Entrez vos identifiants pour continuer.', + 'signin.images.app_logo.alt': "Logo de l'application", /* Base Sign Up */ 'signup.heading': "S'inscrire", diff --git a/packages/javascript/src/i18n/translations/hi-IN.ts b/packages/javascript/src/i18n/translations/hi-IN.ts index f7f252d8..e663bb3f 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -59,6 +59,7 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.heading': 'साइन इन', 'signin.subheading': 'जारी रखने के लिए अपनी प्रमाणिक जानकारी दर्ज करें।', + 'signin.images.app_logo.alt': 'एप्लिकेशन लोगो', /* Base Sign Up */ 'signup.heading': 'साइन अप', diff --git a/packages/javascript/src/i18n/translations/ja-JP.ts b/packages/javascript/src/i18n/translations/ja-JP.ts index 9df3da55..330ada26 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -59,6 +59,7 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.heading': 'ログイン', 'signin.subheading': '続行するには認証情報を入力してください。', + 'signin.images.app_logo.alt': 'アプリケーションロゴ', /* Base Sign Up */ 'signup.heading': 'サインアップ', diff --git a/packages/javascript/src/i18n/translations/pt-BR.ts b/packages/javascript/src/i18n/translations/pt-BR.ts index b7e39630..73896cdd 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -59,6 +59,7 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.heading': 'Entrar', 'signin.subheading': 'Digite suas credencias para continuar.', + 'signin.images.app_logo.alt': 'Logotipo do aplicativo', /* Base Sign Up */ 'signup.heading': 'Cadastra-se', diff --git a/packages/javascript/src/i18n/translations/pt-PT.ts b/packages/javascript/src/i18n/translations/pt-PT.ts index 6580795c..d63dbeca 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -59,6 +59,7 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.heading': 'Iniciar Sessão', 'signin.subheading': 'Introduza as suas credenciais para continuar.', + 'signin.images.app_logo.alt': 'Logótipo da aplicação', /* Base Sign Up */ 'signup.heading': 'Registar-se', diff --git a/packages/javascript/src/i18n/translations/si-LK.ts b/packages/javascript/src/i18n/translations/si-LK.ts index 16a9e117..a98528df 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -59,6 +59,7 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.heading': 'ලොග් වෙන්න', 'signin.subheading': 'ඉදිරියට යාමට ඔබේ සත්‍යාපන තොරතුරු ඇතුළත් කරන්න.', + 'signin.images.app_logo.alt': 'යෙදුමේ ලාංඡනය', /* Base Sign Up */ 'signup.heading': 'ලියාපදිංචි වන්න', diff --git a/packages/javascript/src/i18n/translations/ta-IN.ts b/packages/javascript/src/i18n/translations/ta-IN.ts index 979f91ab..ef119eec 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -59,6 +59,7 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.heading': 'உள்நுழை', 'signin.subheading': 'தொடர உங்கள் சான்றுகளை உள்ளிடவும்.', + 'signin.images.app_logo.alt': 'பயன்பாட்டு லோகோ', /* Base Sign Up */ 'signup.heading': 'பதிவு செய்', diff --git a/packages/javascript/src/i18n/translations/te-IN.ts b/packages/javascript/src/i18n/translations/te-IN.ts index 03c0639f..6599f40d 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -59,6 +59,7 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.heading': 'సైన్ ఇన్ చేయండి', 'signin.subheading': 'కొనసాగించడానికి మీ వివరాలు ఇవ్వండి.', + 'signin.images.app_logo.alt': 'అప్లికేషన్ లోగో', /* Base Sign Up */ 'signup.heading': 'సైన్ అప్ చేయండి', diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index c91afd8d..38ccc6e6 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -39,6 +39,14 @@ export type ThunderIDClientProviderProps = Partial & { applicationId: ThunderIDContextProps['applicationId']; clearSession: () => Promise; + /** + * Server Action that fetches `GET /flow/meta` server-side, passed down to `FlowMetaProvider` so + * it never calls `baseUrl` directly from the browser — avoiding the CORS requirement that a + * direct client-side fetch to the ThunderID server would otherwise impose. Used for both the + * initial fetch (when `initialMeta` isn't seeded, e.g. it failed server-side) and subsequent + * `switchLanguage()` calls. + */ + fetchMeta?: (params: {applicationId?: string; language?: string}) => Promise; handleOAuthCallback: ( code: string, state: string, @@ -84,6 +92,7 @@ const ThunderIDClientProvider: FC) => { const reRenderCheckRef: RefObject = useRef(false); const router: AppRouterInstance = useRouter(); @@ -382,7 +391,11 @@ const ThunderIDClientProvider: FC - + diff --git a/packages/nextjs/src/server/ThunderIDProvider.tsx b/packages/nextjs/src/server/ThunderIDProvider.tsx index ec73cbcb..db88f18b 100644 --- a/packages/nextjs/src/server/ThunderIDProvider.tsx +++ b/packages/nextjs/src/server/ThunderIDProvider.tsx @@ -28,6 +28,7 @@ import signInAction from './actions/signInAction'; import signOutAction from './actions/signOutAction'; import signUpAction from './actions/signUpAction'; import updateUserProfileAction from './actions/updateUserProfileAction'; +import getFlowMetaAction from './actions/getFlowMetaAction'; import getClient from './getClient'; import ThunderIDClientProvider from '../client/contexts/ThunderID/ThunderIDProvider.js'; import {ThunderIDNextConfig} from '../models/config'; @@ -195,6 +196,7 @@ const ThunderIDServerProvider: FC => { + const client = getClient(); + const config: ThunderIDNextConfig = await client.getConfiguration(); + + const applicationId: string | undefined = params?.applicationId ?? config?.applicationId; + + try { + return await getFlowMeta({ + baseUrl: config?.baseUrl, + url: resolveResourceEndpoint('flowMeta', config), + ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), + ...(params?.language ? {language: params.language} : {}), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[getFlowMetaAction] Error fetching flow metadata: ${message}`); + throw error; + } +}; + +export default getFlowMetaAction; diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index ee66fae4..f91cb237 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -60,8 +60,9 @@ export default defineNuxtModule({ }, ); - const privateConfig: {clientSecret: string; sessionSecret: string} = { + const privateConfig: {clientSecret: string; flowSecret: string; sessionSecret: string} = { clientSecret: process.env.THUNDERID_CLIENT_SECRET || userOptions.clientSecret || '', + flowSecret: process.env.THUNDERID_FLOW_SECRET || userOptions.flowSecret || '', sessionSecret: process.env.THUNDERID_SESSION_SECRET || userOptions.sessionSecret || '', }; @@ -78,7 +79,7 @@ export default defineNuxtModule({ options.runtimeConfig.thunderid = defu( (options.runtimeConfig.thunderid as Record) || {}, privateConfig, - ) as {clientSecret: string; sessionSecret: string}; + ) as {clientSecret: string; flowSecret: string; sessionSecret: string}; options.runtimeConfig.public.thunderid = defu( (options.runtimeConfig.public.thunderid as Record) || {}, @@ -129,6 +130,13 @@ export default defineNuxtModule({ `[${PACKAGE_NAME}] SECURITY: sessionSecret found in public config. Removed. Use THUNDERID_SESSION_SECRET env var.`, ); } + if (publicThunderID?.flowSecret) { + delete publicThunderID.flowSecret; + // eslint-disable-next-line no-console + console.error( + `[${PACKAGE_NAME}] SECURITY: flowSecret found in public config. Removed. Use THUNDERID_FLOW_SECRET env var.`, + ); + } // Register server API routes const serverRoutes: ServerRoute[] = [ @@ -158,6 +166,7 @@ export default defineNuxtModule({ // ── Session / token ─────────────────────────────────────────────── {handler: resolve('./runtime/server/routes/auth/session/session.get'), route: '/api/auth/session'}, {handler: resolve('./runtime/server/routes/auth/session/token.get'), route: '/api/auth/token'}, + {handler: resolve('./runtime/server/routes/auth/session/meta.get'), route: '/api/auth/meta'}, // ── User ────────────────────────────────────────────────────────── {handler: resolve('./runtime/server/routes/auth/user/user.get'), route: '/api/auth/user'}, {handler: resolve('./runtime/server/routes/auth/user/profile.get'), route: '/api/auth/user/profile'}, @@ -208,41 +217,40 @@ export default defineNuxtModule({ name: 'ThunderIDRoot', }); - // Register Nuxt-specific component containers with the `ThunderID` prefix. + // Register Nuxt-specific component containers under their bare names — + // matching the unprefixed names @thunderid/react and @thunderid/vue already + // export (`SignInButton`, `SignIn`, `UserProfile`, etc.). // // Each container lives at `./runtime/components/.ts` and: // 1. Imports the corresponding BaseXxx from @thunderid/vue (not the Vue container). // 2. Wires composables through `#imports` (Nuxt auto-import layer). // 3. Uses `navigateTo` from `#app` for all navigation — SSR-safe, no window.location. // - // This mirrors the Next.js SDK pattern where Base components come from - // @thunderid/react and host-specific containers live in the Next.js package. - // // NOTE: Composables (useUser, useTheme, useFlow, useI18n) remain direct // re-exports from @thunderid/vue via addImports above — only the components // need Nuxt wrappers. // ── Control flow ──────────────────────────────────────────────────────── - addComponent({filePath: resolve('./runtime/components/control/SignedIn'), name: 'ThunderIDSignedIn'}); - addComponent({filePath: resolve('./runtime/components/control/SignedOut'), name: 'ThunderIDSignedOut'}); - addComponent({filePath: resolve('./runtime/components/control/Loading'), name: 'ThunderIDLoading'}); + addComponent({filePath: resolve('./runtime/components/control/SignedIn'), name: 'SignedIn'}); + addComponent({filePath: resolve('./runtime/components/control/SignedOut'), name: 'SignedOut'}); + addComponent({filePath: resolve('./runtime/components/control/Loading'), name: 'Loading'}); // ── Action buttons ─────────────────────────────────────────────────────── - addComponent({filePath: resolve('./runtime/components/actions/SignInButton'), name: 'ThunderIDSignInButton'}); - addComponent({filePath: resolve('./runtime/components/actions/SignOutButton'), name: 'ThunderIDSignOutButton'}); - addComponent({filePath: resolve('./runtime/components/actions/SignUpButton'), name: 'ThunderIDSignUpButton'}); + addComponent({filePath: resolve('./runtime/components/actions/SignInButton'), name: 'SignInButton'}); + addComponent({filePath: resolve('./runtime/components/actions/SignOutButton'), name: 'SignOutButton'}); + addComponent({filePath: resolve('./runtime/components/actions/SignUpButton'), name: 'SignUpButton'}); // ── Embedded auth flows ────────────────────────────────────────────────── - addComponent({filePath: resolve('./runtime/components/auth/SignIn'), name: 'ThunderIDSignIn'}); - addComponent({filePath: resolve('./runtime/components/auth/SignUp'), name: 'ThunderIDSignUp'}); + addComponent({filePath: resolve('./runtime/components/auth/SignIn'), name: 'SignIn'}); + addComponent({filePath: resolve('./runtime/components/auth/SignUp'), name: 'SignUp'}); // ── User ───────────────────────────────────────────────────────────────── - addComponent({filePath: resolve('./runtime/components/user/User'), name: 'ThunderIDUser'}); - addComponent({filePath: resolve('./runtime/components/user/UserProfile'), name: 'ThunderIDUserProfile'}); - addComponent({filePath: resolve('./runtime/components/user/UserDropdown'), name: 'ThunderIDUserDropdown'}); + 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'}); // ── Auth callback ──────────────────────────────────────────────────────── - addComponent({filePath: resolve('./runtime/components/auth/Callback'), name: 'ThunderIDCallback'}); + addComponent({filePath: resolve('./runtime/components/auth/Callback'), name: 'Callback'}); // Tell Vite to pre-bundle the CJS-only packages that @thunderid/browser, // @thunderid/javascript, and @thunderid/vue carry as external dependencies. @@ -300,6 +308,7 @@ declare module '@nuxt/schema' { interface RuntimeConfig { thunderid: { clientSecret: string; + flowSecret: string; sessionSecret: string; }; } diff --git a/packages/nuxt/src/runtime/components/ThunderIDRoot.ts b/packages/nuxt/src/runtime/components/ThunderIDRoot.ts index 76e728ce..44f395b9 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 {AttributeSchema, UpdateMeProfileConfig, User, UserProfile} from '@thunderid/node'; +import type {AttributeSchema, FlowMetadataResponse, 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, getUserSchemaStateKey} from '../utils/stateKeys'; +import {getAuthStateKey, getFlowMetaStateKey, getUserProfileStateKey, getUserSchemaStateKey} from '../utils/stateKeys'; import {useState, useRuntimeConfig} from '#imports'; /** @@ -61,6 +61,9 @@ const ThunderIDRoot: Component = defineComponent({ > | null>(getUserSchemaStateKey(vendor)); // Used by onUpdateProfile to keep the top-level auth user claim in sync. const authState: Ref = useState(getAuthStateKey(vendor)); + const flowMetaState: Ref = useState( + getFlowMetaStateKey(vendor), + ); // ── Preferences from runtime config ──────────────────────────────────── const prefs: ThunderIDNuxtConfig['preferences'] | undefined = runtimeThunderIDConfig?.preferences; @@ -101,8 +104,8 @@ const ThunderIDRoot: Component = defineComponent({ * Signature matches `UserProvider.updateProfile` exactly. * * On success, applies an optimistic local update via `onUpdateProfile` - * so consumers of `useUser()` (e.g. ``) and - * `useThunderID().user` (e.g. ``) reflect the new value + * so consumers of `useUser()` (e.g. ``) and + * `useThunderID().user` (e.g. ``) reflect the new value * without waiting for the next navigation/SSR refetch. */ const updateProfile = async ( @@ -138,15 +141,19 @@ const ThunderIDRoot: Component = defineComponent({ } }; + /** + * Fetches flow metadata via the `/api/auth/meta` Nitro route instead of `FlowMetaProvider`'s + * default direct browser-to-`baseUrl` fetch — so the browser never talks to the ThunderID + * server directly and no CORS configuration is required there. Used for both the initial + * fetch (when SSR seeding via `flowMetaState` didn't happen, e.g. it failed server-side) and + * subsequent `switchLanguage()` calls. + */ + const fetchMeta = async (params: {applicationId?: string; language?: string}): Promise => + $fetch('/api/auth/meta', { + query: {...(params.language ? {language: params.language} : {})}, + }); + // ── Render tree — mirrors ThunderIDClientProvider (Next.js) ───────────── - // - // FlowMetaProvider is mounted unconditionally with `enabled: false` (V1 - // platform default). It still provides `FLOW_META_KEY` to descendants so - // `useFlowMeta()` (called by `BaseSignUp`, v2 `BaseSignIn`, - // `BaseAcceptInvite`, `BaseInviteUser`) returns a real context with - // `meta: null` instead of throwing. When the Nuxt SDK gains a `platform` - // config option, derive `enabled` from it the same way `ThunderIDProvider` - // does (`enabled: platform === Platform.ThunderID`). return (): VNode => h( I18nProvider, @@ -155,7 +162,7 @@ const ThunderIDRoot: Component = defineComponent({ default: (): VNode => h( FlowMetaProvider, - {enabled: false}, + {enabled: true, fetchMeta, initialMeta: flowMetaState.value}, { default: (): VNode => h( diff --git a/packages/nuxt/src/runtime/components/actions/SignInButton.ts b/packages/nuxt/src/runtime/components/actions/SignInButton.ts index 8f49107c..1a7e8b83 100644 --- a/packages/nuxt/src/runtime/components/actions/SignInButton.ts +++ b/packages/nuxt/src/runtime/components/actions/SignInButton.ts @@ -20,8 +20,8 @@ import {useThunderID} from '#imports'; * * @example * ```vue - * - * Log in + * + * Log in * ``` */ const SignInButton: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/components/actions/SignOutButton.ts b/packages/nuxt/src/runtime/components/actions/SignOutButton.ts index 2f61bb6d..2f06e2b3 100644 --- a/packages/nuxt/src/runtime/components/actions/SignOutButton.ts +++ b/packages/nuxt/src/runtime/components/actions/SignOutButton.ts @@ -16,8 +16,8 @@ import {useThunderID} from '#imports'; * * @example * ```vue - * - * Sign out + * + * Sign out * ``` */ const SignOutButton: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/components/actions/SignUpButton.ts b/packages/nuxt/src/runtime/components/actions/SignUpButton.ts index d6e2c1de..ab4588cd 100644 --- a/packages/nuxt/src/runtime/components/actions/SignUpButton.ts +++ b/packages/nuxt/src/runtime/components/actions/SignUpButton.ts @@ -16,8 +16,8 @@ import {useThunderID} from '#imports'; * * @example * ```vue - * - * Create account + * + * Create account * ``` */ const SignUpButton: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/components/auth/Callback.ts b/packages/nuxt/src/runtime/components/auth/Callback.ts index 0fab2271..e7b52df8 100644 --- a/packages/nuxt/src/runtime/components/auth/Callback.ts +++ b/packages/nuxt/src/runtime/components/auth/Callback.ts @@ -33,7 +33,7 @@ interface CallbackSetupProps { * ```vue * * * ``` */ diff --git a/packages/nuxt/src/runtime/components/auth/SignIn.ts b/packages/nuxt/src/runtime/components/auth/SignIn.ts index 71e82420..702649e0 100644 --- a/packages/nuxt/src/runtime/components/auth/SignIn.ts +++ b/packages/nuxt/src/runtime/components/auth/SignIn.ts @@ -1,27 +1,89 @@ -// Copyright 2025 The ThunderID Authors +// Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 import {navigateTo} from '#app'; -import {BaseSignIn} from '@thunderid/vue'; -import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import { + ConsentConstants, + ThunderIDRuntimeError, + type ConsentPurposeData, + type EmbeddedFlowComponent, + EmbeddedFlowType, + type EmbeddedSignInFlowRequest, + type EmbeddedSignInFlowResponse, + EmbeddedSignInFlowStatus, + EmbeddedSignInFlowType, + type FlowMetadataResponse, +} from '@thunderid/browser'; +import { + BaseSignIn, + extractErrorMessage, + handlePasskeyAuthentication, + handlePasskeyRegistration, + initiateOAuthRedirect, + normalizeFlowResponse, + useFlowMeta, + useI18n, + useOAuthCallback, + type OAuthCallbackPayload, +} from '@thunderid/vue'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + defineComponent, + h, + onUnmounted, + ref, + watch, +} from 'vue'; import {useThunderID} from '#imports'; +interface PasskeyState { + actionId: string | null; + challenge: string | null; + creationOptions: string | null; + error: Error | null; + executionId: string | null; + isActive: boolean; +} + +/** + * Render props passed to the default scoped slot for custom UI rendering. + */ +export interface SignInRenderProps { + additionalData?: Record; + components: EmbeddedFlowComponent[]; + error: Error | null; + initialize: () => Promise; + isInitialized: boolean; + isLoading: boolean; + isTimeoutDisabled?: boolean; + meta: FlowMetadataResponse | null; + onSubmit: (payload: EmbeddedSignInFlowRequest) => Promise; +} + /** * Nuxt-specific SignIn container for the embedded (app-native) sign-in flow. * - * Mirrors the Vue SDK's `SignIn` container but replaces all `window.location` - * navigation with Nuxt's `navigateTo` so redirects after a successful embedded - * sign-in are SSR-safe. + * Unlike `BaseSignUp` (which drives its own flow lifecycle internally), + * `BaseSignIn` from `@thunderid/vue` is a pure rendering component: it only + * ever calls `props.onSubmit` and expects the caller to own flow state + * (`components`, `executionId`, loading/error) and pass it down as props. + * This container is that owner, mirroring the Vue SDK's `SignIn` container + * almost line-for-line, with two Nuxt-specific differences: * - * Uses `useThunderID()` from the Nuxt auto-import layer — the Nuxt-specific - * wrapper that provides Nitro-route-aware `signIn`, `signOut`, `signUp`. - * - * Delegates all UI rendering to {@link BaseSignIn} from `@thunderid/vue`, which - * itself is platform-aware (routes to V1 authenticator or V2 component flow). + * 1. All navigation goes through Nuxt's `navigateTo` instead of + * `window.location`, so redirects work correctly during SSR. + * 2. Flow completion is detected via the synthesized + * `{ flowStatus: Complete }` response from the Nuxt `useThunderID().signIn` + * composable (the server has already consumed the flow assertion and set + * the session cookie in `signin.post.ts`), not a `redirectUrl` field. * * @example * ```vue - * + * * ``` */ const SignIn: Component = defineComponent({ @@ -40,53 +102,609 @@ const SignIn: Component = defineComponent({ }, setup( props: Readonly<{className: string; size: 'small' | 'medium' | 'large'; variant: 'elevated' | 'outlined' | 'flat'}>, - {emit, attrs}: SetupContext, - ): () => VNode { - const {signIn, afterSignInUrl, isInitialized, isLoading} = useThunderID(); + {slots, emit, attrs}: SetupContext, + ): () => VNode | null { + const { + applicationId, + afterSignInUrl, + signIn, + isInitialized, + isLoading: sdkLoading, + scopes, + getStorageManager, + vendor, + } = useThunderID(); + const {meta: flowMeta} = useFlowMeta(); + const {t} = useI18n(); + + const executionIdStorageKey = `${vendor}_execution_id`; + + // Flow state + const components: Ref = ref([]); + const additionalData: Ref> = ref({}); + const currentExecutionId: Ref = ref(null); + const challengeTokenRef: {current: string | null} = {current: null}; + const isFlowInitialized: Ref = ref(false); + const flowError: Ref = ref(null); + const isSubmitting: Ref = ref(false); + const isTimeoutDisabled: Ref = ref(false); + const passkeyState: Ref = ref({ + actionId: null, + challenge: null, + creationOptions: null, + error: null, + executionId: null, + isActive: false, + }); - const handleInitialize = async (): Promise => - // Pass flowId='' to trigger the embedded-flow initiation path in useThunderID. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await signIn({flowId: ''} as any, {} as any); + // Track one-time initialization and OAuth processing + let initializationAttempted = false; + const oauthCodeProcessedFlag: {value: boolean} = {value: false}; + let passkeyProcessed = false; + + // ── Helpers ────────────────────────────────────────────────────────── + + const persistExecutionId = (executionId: string | null): void => { + currentExecutionId.value = executionId; + if (!import.meta.client) return; + if (executionId) { + sessionStorage.setItem(executionIdStorageKey, executionId); + } else { + sessionStorage.removeItem(executionIdStorageKey); + } + }; + + const clearFlowState = async (): Promise => { + persistExecutionId(null); + isFlowInitialized.value = false; + await setChallengeToken(null); + const sm = getStorageManager?.(); + if (sm) { + await sm.removeHybridDataParameter('authId'); + } + isTimeoutDisabled.value = false; + oauthCodeProcessedFlag.value = false; + }; - const handleOnSubmit = async (payload: any, request: any): Promise => await signIn(payload, request); + interface UrlParams { + applicationId: string | null; + authId: string | null; + code: string | null; + error: string | null; + errorDescription: string | null; + executionId: string | null; + nonce: string | null; + state: string | null; + } + + const emptyUrlParams: UrlParams = { + applicationId: null, + authId: null, + code: null, + error: null, + errorDescription: null, + executionId: null, + nonce: null, + state: null, + }; + + // `window` is unavailable during SSR — every caller falls back to empty params. + const getUrlParams = (): UrlParams => { + if (!import.meta.client) return emptyUrlParams; + const params: URLSearchParams = new URLSearchParams(window?.location?.search ?? ''); + return { + applicationId: params.get('applicationId'), + authId: params.get('authId'), + code: params.get('code'), + error: params.get('error'), + errorDescription: params.get('error_description'), + executionId: params.get('executionId'), + nonce: params.get('nonce'), + state: params.get('state'), + }; + }; + + const cleanupOAuthUrlParams = (): void => { + if (!import.meta.client || !window?.location?.href) return; + const url: URL = new URL(window.location.href); + ['error', 'error_description', 'code', 'state', 'nonce'].forEach((p: string) => url.searchParams.delete(p)); + window.history.replaceState({}, '', url.toString()); + }; + + const cleanupFlowUrlParams = (): void => { + if (!import.meta.client || !window?.location?.href) return; + const url: URL = new URL(window.location.href); + ['executionId', 'authId', 'applicationId'].forEach((p: string) => url.searchParams.delete(p)); + window.history.replaceState({}, '', url.toString()); + }; + + const setError = (error: Error): void => { + flowError.value = error; + isFlowInitialized.value = true; + emit('error', error); + }; + + /** + * Updates challengeTokenRef immediately and persists via the provider's + * StorageManager so the token survives OAuth redirects. + */ + const setChallengeToken = async (challengeToken: string | null): Promise => { + challengeTokenRef.current = challengeToken; + if (!import.meta.client) return; + try { + const sm = getStorageManager?.(); + if (sm) { + if (challengeToken) { + await sm.setTemporaryDataParameter('challengeToken', challengeToken); + } else { + await sm.removeTemporaryDataParameter('challengeToken'); + } + } + } catch { + // Ignore storage failures; the in-memory ref still has the current value. + } + }; - const handleSuccess = async (authData: Record): Promise => { + // ── Flow completion — establishes redirect via afterSignInUrl ────────── + // The Nuxt `signIn()` composable synthesizes `{authData: {}, flowStatus: + // Complete}` once the server has consumed the flow assertion and set the + // session cookie (see `signin.post.ts`); there is no `redirectUrl` field + // to read here (unlike the pure-client Vue/React flow), so the redirect + // target is always `afterSignInUrl`. + const handleComplete = async (authData: Record): Promise => { emit('success', authData); + persistExecutionId(null); + isFlowInitialized.value = false; + + if (!afterSignInUrl) return; + + if (import.meta.client) { + const url: URL = new URL(afterSignInUrl as string, window.location.origin); + Object.entries(authData || {}).forEach(([key, value]: [string, any]) => { + if (value !== undefined && value !== null) { + url.searchParams.append(key, String(value)); + } + }); + await navigateTo(url.pathname + url.search + url.hash); + } else { + await navigateTo(afterSignInUrl as string); + } + }; + + // ── Flow initialization ─────────────────────────────────────────────── + + const initializeFlow = async (): Promise => { + const urlParams: UrlParams = getUrlParams(); + + oauthCodeProcessedFlag.value = false; + + // Restore any challenge token persisted before an OAuth redirect. + if (import.meta.client) { + try { + const sm = getStorageManager?.(); + const tempData: any = await sm?.getTemporaryData?.(); + if (tempData?.challengeToken) { + challengeTokenRef.current = tempData.challengeToken as string; + } + } catch { + // Ignore — the flow will re-fetch a fresh challengeToken from the next response. + } + } + + if (urlParams.authId) { + const sm = getStorageManager?.(); + if (sm) { + await sm.setHybridDataParameter('authId', urlParams.authId); + } + } + + const effectiveApplicationId: string | null | undefined = applicationId || urlParams.applicationId; + + if (!urlParams.executionId && !effectiveApplicationId) { + const err: ThunderIDRuntimeError = new ThunderIDRuntimeError( + 'Either executionId or applicationId is required for authentication', + 'SIGN_IN_ERROR', + 'nuxt', + ); + setError(err); + throw err; + } + + try { + flowError.value = null; + + const response: EmbeddedSignInFlowResponse = ( + urlParams.executionId + ? await signIn({executionId: urlParams.executionId}) + : await signIn({ + applicationId: effectiveApplicationId, + flowType: EmbeddedFlowType.Authentication, + ...(scopes && {scopes}), + }) + ) as EmbeddedSignInFlowResponse; + + if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) { + isFlowInitialized.value = true; + await handleComplete((response as any).authData || {}); + return; + } + + // Handle OAuth redirect types + if (response.type === EmbeddedSignInFlowType.Redirection) { + const redirectURL: string | undefined = (response.data as any)?.redirectURL || (response as any)?.redirectURL; + if (redirectURL && import.meta.client) { + if (response.executionId) persistExecutionId(response.executionId); + if (urlParams.authId) { + const sm = getStorageManager?.(); + if (sm) { + await sm.setHybridDataParameter('authId', urlParams.authId); + } + } + initiateOAuthRedirect(redirectURL, vendor); + return; + } + } + + const { + executionId: normalizedExecutionId, + components: normalizedComponents, + additionalData: normalizedAdditionalData, + } = normalizeFlowResponse(response, t, {resolveTranslations: false}, flowMeta.value); + + if (normalizedExecutionId && normalizedComponents) { + await setChallengeToken(response.challengeToken ?? null); + persistExecutionId(normalizedExecutionId); + components.value = normalizedComponents; + additionalData.value = normalizedAdditionalData ?? {}; + isFlowInitialized.value = true; + isTimeoutDisabled.value = false; + cleanupFlowUrlParams(); + } + } catch (error: unknown) { + const err: any = error as any; + clearFlowState(); + setError(new Error(extractErrorMessage(err, t))); + initializationAttempted = false; + } + }; + + // ── Submit handler ──────────────────────────────────────────────────── + + const handleSubmit = async (payload: EmbeddedSignInFlowRequest): Promise => { + const effectiveExecutionId: string | null = payload.executionId || currentExecutionId.value; + + if (!effectiveExecutionId) { + throw new Error('No active flow ID'); + } + + const processedInputs: Record = {...payload.inputs}; - if (authData && afterSignInUrl) { - if (import.meta.client) { - // Build the full URL with auth data params (client-only: needs window.location.origin). - const url: URL = new URL(afterSignInUrl as string, window.location.origin); - Object.entries(authData).forEach(([key, value]: [string, any]) => { - if (value !== undefined && value !== null) { - url.searchParams.append(key, String(value)); + // Auto-compile consent decisions if on a consent prompt step + if (additionalData.value?.['consentPrompt']) { + try { + const consentRaw: any = additionalData.value['consentPrompt']; + const purposes: ConsentPurposeData[] = + typeof consentRaw === 'string' ? JSON.parse(consentRaw) : consentRaw.purposes || consentRaw; + + let isDeny = false; + if (payload.action) { + const findAction = (comps: any[]): any => { + if (!comps?.length) return null; + const found: any = comps.find((c: any) => c.id === payload.action); + if (found) return found; + return comps.reduce((acc: any, c: any) => acc || (c.components ? findAction(c.components) : null), null); + }; + const submitAction: any = findAction(components.value as any[]); + if (submitAction && submitAction.variant?.toLowerCase() !== 'primary') { + isDeny = true; } + } + + const decisions: Record = { + approved: !isDeny, + ...(isDeny ? {reason: ConsentConstants.REASON_USER_DENIED} : {}), + purposes: purposes.map((p) => ({ + approved: !isDeny, + elements: [ + ...(p.essential ?? []).map((e) => ({approved: !isDeny, name: e.name})), + ...(p.optional ?? []).map((e) => { + const key = `__consent_opt__${p.purposeId}__${e.name}`; + return {approved: !isDeny && processedInputs[key] === 'true', name: e.name}; + }), + ], + purposeName: p.purposeName, + })), + }; + processedInputs['consent_decisions'] = JSON.stringify(decisions); + + Object.keys(processedInputs).forEach((key: string) => { + if (key.startsWith('__consent_opt__')) delete processedInputs[key]; }); - await navigateTo(url.pathname + url.search + url.hash); - } else { - // On SSR, just navigate to the base afterSignInUrl (no auth data params). - await navigateTo(afterSignInUrl as string); + } catch { + // Ignore consent construction failures + } + } + + try { + isSubmitting.value = true; + flowError.value = null; + + const response: EmbeddedSignInFlowResponse = (await signIn({ + executionId: effectiveExecutionId, + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + ...payload, + inputs: processedInputs, + })) as EmbeddedSignInFlowResponse; + + // Handle OAuth redirect + if (response.type === EmbeddedSignInFlowType.Redirection) { + const redirectURL: string | undefined = (response.data as any)?.redirectURL || (response as any)?.redirectURL; + if (redirectURL && import.meta.client) { + if (response.executionId) persistExecutionId(response.executionId); + const urlParams: UrlParams = getUrlParams(); + if (urlParams.authId) { + const sm = getStorageManager?.(); + if (sm) { + await sm.setHybridDataParameter('authId', urlParams.authId); + } + } + initiateOAuthRedirect(redirectURL, vendor); + return; + } + } + + // Handle passkey challenge in response + if ( + response.data?.additionalData?.['passkeyChallenge'] || + response.data?.additionalData?.['passkeyCreationOptions'] + ) { + const {passkeyChallenge, passkeyCreationOptions} = response.data.additionalData as any; + passkeyProcessed = false; + passkeyState.value = { + actionId: 'submit', + challenge: passkeyChallenge || null, + creationOptions: passkeyCreationOptions || null, + error: null, + executionId: response.executionId || effectiveExecutionId, + isActive: true, + }; + isSubmitting.value = false; + return; + } + + // Handle error flow status + if (response.flowStatus === EmbeddedSignInFlowStatus.Error) { + clearFlowState(); + const err: Error = new Error(extractErrorMessage(response, t)); + setError(err); + cleanupFlowUrlParams(); + throw err; + } + + // Handle flow completion + if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) { + isSubmitting.value = false; + await setChallengeToken(null); + await handleComplete((response as any).authData || {}); + return; + } + + await setChallengeToken(response.challengeToken ?? null); + + const { + executionId: normalizedExecutionId, + components: normalizedComponents, + additionalData: normalizedAdditionalData, + } = normalizeFlowResponse(response, t, {resolveTranslations: false}, flowMeta.value); + + // Update flow state for next step + if (normalizedExecutionId && normalizedComponents) { + persistExecutionId(normalizedExecutionId); + components.value = normalizedComponents; + additionalData.value = normalizedAdditionalData ?? {}; + isTimeoutDisabled.value = false; + isFlowInitialized.value = true; + cleanupFlowUrlParams(); + + if ((response as any)?.error) { + flowError.value = new Error(extractErrorMessage(response, t)); + } } + } catch (error: unknown) { + const err: any = error as any; + if (err instanceof Error && flowError.value === err) { + // Already set; re-throw + throw err; + } + clearFlowState(); + setError(new Error(extractErrorMessage(err, t))); + } finally { + isSubmitting.value = false; + } + }; + + // ── Step timeout ────────────────────────────────────────────────────── + + let timeoutHandle: ReturnType | null = null; + + const scheduleTimeout = (timeoutMs: number): void => { + if (timeoutHandle) clearTimeout(timeoutHandle); + if (timeoutMs <= 0 || !isFlowInitialized.value) { + isTimeoutDisabled.value = false; + return; + } + const remaining: number = Math.max(0, Math.floor((timeoutMs - Date.now()) / 1000)); + if (remaining <= 0) { + isTimeoutDisabled.value = true; + setError(new Error(t('errors.signin.timeout') || 'Time allowed to complete the step has expired.')); + return; } + timeoutHandle = setTimeout(() => { + isTimeoutDisabled.value = true; + setError(new Error(t('errors.signin.timeout') || 'Time allowed to complete the step has expired.')); + }, remaining * 1000); }; - return (): VNode => - h(BaseSignIn, { + watch( + () => [additionalData.value?.['stepTimeout'], isFlowInitialized.value] as [number | undefined, boolean], + ([timeoutMs]: [number | undefined, boolean]) => { + scheduleTimeout(Number(timeoutMs) || 0); + }, + ); + + onUnmounted(() => { + if (timeoutHandle) clearTimeout(timeoutHandle); + }); + + // ── Passkey processing ──────────────────────────────────────────────── + + watch( + () => passkeyState.value, + async (state: PasskeyState) => { + if (!state.isActive || (!state.challenge && !state.creationOptions) || !state.executionId) return; + if (passkeyProcessed) return; + passkeyProcessed = true; + + try { + let inputs: Record; + + if (state.challenge) { + const passkeyResponse: string = await handlePasskeyAuthentication(state.challenge); + const obj: any = JSON.parse(passkeyResponse); + inputs = { + authenticatorData: obj.response.authenticatorData, + clientDataJSON: obj.response.clientDataJSON, + credentialId: obj.id, + signature: obj.response.signature, + userHandle: obj.response.userHandle, + }; + } else if (state.creationOptions) { + const passkeyResponse: string = await handlePasskeyRegistration(state.creationOptions); + const obj: any = JSON.parse(passkeyResponse); + inputs = { + attestationObject: obj.response.attestationObject, + clientDataJSON: obj.response.clientDataJSON, + credentialId: obj.id, + }; + } else { + throw new Error('No passkey challenge or creation options available'); + } + + await handleSubmit({executionId: state.executionId, inputs}); + + passkeyState.value = { + actionId: null, + challenge: null, + creationOptions: null, + error: null, + executionId: null, + isActive: false, + }; + } catch (error: unknown) { + const err: Error = error as Error; + passkeyState.value = {...passkeyState.value, error: err, isActive: false}; + flowError.value = err; + emit('error', err); + } + }, + {deep: true}, + ); + + // ── OAuth callback (via composable) ───────────────────────────────── + + useOAuthCallback({ + currentFlowId: currentExecutionId, + flowIdStorageKey: executionIdStorageKey, + isInitialized, + isSubmitting, + onError: (err: any) => { + // Guard against double-processing when handleSubmit already set the error + if (!flowError.value) { + clearFlowState(); + setError(err instanceof Error ? err : new Error(String(err))); + } + }, + onSubmit: (payload: OAuthCallbackPayload) => handleSubmit({executionId: payload.flowId, inputs: payload.inputs}), + processedFlag: oauthCodeProcessedFlag, + setFlowId: persistExecutionId, + }); + + // Initialize flow when SDK is ready (OAuth callback is handled by useOAuthCallback) + watch( + () => + [ + isInitialized.value, + sdkLoading?.value ?? false, + isFlowInitialized.value, + currentExecutionId.value, + isSubmitting.value, + ] as [boolean, boolean, boolean, string | null, boolean], + ([initialized, loading, flowInit, executionId, submitting]: [ + boolean, + boolean, + boolean, + string | null, + boolean, + ]) => { + const urlParams: UrlParams = getUrlParams(); + const hasOAuthCode = !!urlParams.code; + const hasOAuthState = !!urlParams.state; + + // Initialize flow when SDK is ready and no flow is active + if ( + initialized && + !loading && + !flowInit && + !initializationAttempted && + !executionId && + !hasOAuthCode && + !hasOAuthState && + !submitting && + !oauthCodeProcessedFlag.value + ) { + initializationAttempted = true; + initializeFlow(); + } + }, + {immediate: true}, + ); + + // ── Render ──────────────────────────────────────────────────────────── + + return (): VNode | null => { + const combinedIsLoading: boolean = (sdkLoading?.value ?? false) || isSubmitting.value || !isInitialized.value; + + // Scoped slot / render props pattern + if (slots['default']) { + const renderProps: SignInRenderProps = { + additionalData: additionalData.value, + components: components.value, + error: flowError.value, + initialize: initializeFlow, + isInitialized: isFlowInitialized.value, + isLoading: combinedIsLoading, + isTimeoutDisabled: isTimeoutDisabled.value, + meta: flowMeta.value, + onSubmit: handleSubmit, + }; + return h('div', {}, slots['default'](renderProps)); + } + + // Default BaseSignIn rendering + return h(BaseSignIn, { ...attrs, - afterSignInUrl, + additionalData: additionalData.value, class: props.className, - isLoading: (isLoading?.value ?? false) || !(isInitialized?.value ?? true), + components: components.value, + error: flowError.value, + isLoading: combinedIsLoading || !isFlowInitialized.value, + isTimeoutDisabled: isTimeoutDisabled.value, onError: (err: Error) => emit('error', err), - onInitialize: handleInitialize, - onSubmit: handleOnSubmit, - onSuccess: handleSuccess, - showLogo: true, - showSubtitle: true, - showTitle: true, + onSubmit: handleSubmit, size: props.size, variant: props.variant, }); + }; }, }); diff --git a/packages/nuxt/src/runtime/components/auth/SignUp.ts b/packages/nuxt/src/runtime/components/auth/SignUp.ts index a7c5c5a3..8686a5c7 100644 --- a/packages/nuxt/src/runtime/components/auth/SignUp.ts +++ b/packages/nuxt/src/runtime/components/auth/SignUp.ts @@ -25,7 +25,7 @@ export type SignUpRenderProps = BaseSignUpRenderProps; * * @example * ```vue - * + * * ``` */ const SignUp: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/components/control/Loading.ts b/packages/nuxt/src/runtime/components/control/Loading.ts index 1ce61f08..38f5460c 100644 --- a/packages/nuxt/src/runtime/components/control/Loading.ts +++ b/packages/nuxt/src/runtime/components/control/Loading.ts @@ -14,10 +14,10 @@ import {useThunderID} from '#imports'; * * @example * ```vue - * + * * * - * + * * ``` */ const Loading: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/components/control/SignedIn.ts b/packages/nuxt/src/runtime/components/control/SignedIn.ts index e60aebb5..007611c8 100644 --- a/packages/nuxt/src/runtime/components/control/SignedIn.ts +++ b/packages/nuxt/src/runtime/components/control/SignedIn.ts @@ -16,10 +16,10 @@ import {useThunderID} from '#imports'; * * @example * ```vue - * + * *

Welcome!

* - *
+ * * ``` */ const SignedIn: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/components/control/SignedOut.ts b/packages/nuxt/src/runtime/components/control/SignedOut.ts index 9b08a8cb..d66bc2db 100644 --- a/packages/nuxt/src/runtime/components/control/SignedOut.ts +++ b/packages/nuxt/src/runtime/components/control/SignedOut.ts @@ -15,10 +15,10 @@ import {useThunderID} from '#imports'; * * @example * ```vue - * + * *

Please sign in.

* - *
+ * * ``` */ const SignedOut: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/components/user/User.ts b/packages/nuxt/src/runtime/components/user/User.ts index 9ed9cf7f..63e5b355 100644 --- a/packages/nuxt/src/runtime/components/user/User.ts +++ b/packages/nuxt/src/runtime/components/user/User.ts @@ -14,12 +14,12 @@ import {useThunderID} from '#imports'; * * @example * ```vue - * + * * * - * + *
* ``` */ const User: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/components/user/UserDropdown.ts b/packages/nuxt/src/runtime/components/user/UserDropdown.ts index 06d71f54..cdf365f5 100644 --- a/packages/nuxt/src/runtime/components/user/UserDropdown.ts +++ b/packages/nuxt/src/runtime/components/user/UserDropdown.ts @@ -21,7 +21,7 @@ import {useThunderID, useUser} from '#imports'; * * @example * ```vue - * + * * ``` */ const UserDropdown: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/components/user/UserProfile.ts b/packages/nuxt/src/runtime/components/user/UserProfile.ts index 79d84d85..196b89da 100644 --- a/packages/nuxt/src/runtime/components/user/UserProfile.ts +++ b/packages/nuxt/src/runtime/components/user/UserProfile.ts @@ -18,7 +18,7 @@ import {useUser} from '#imports'; * * @example * ```vue - * + * * ``` */ const UserProfile: Component = defineComponent({ diff --git a/packages/nuxt/src/runtime/composables/useThunderID.ts b/packages/nuxt/src/runtime/composables/useThunderID.ts index 0d2f3b56..9ba6c7e3 100644 --- a/packages/nuxt/src/runtime/composables/useThunderID.ts +++ b/packages/nuxt/src/runtime/composables/useThunderID.ts @@ -32,19 +32,23 @@ export function useThunderID(): ThunderIDContext { /** * Sign in the user. * - * **Embedded flow**: call with `(payload, request)` where `payload` has a - * `flowId` property (use `{flowId: ''}` to initiate). The method POSTs to - * `/api/auth/signin` and returns the flow-step response or redirects on - * completion. + * **Embedded flow**: call with `(payload, request)` where `payload` has an + * `applicationId` (start a new flow) or `executionId` (continue one) property + * — mirrors `ThunderIDNuxtClient.signIn`'s own payload detection server-side. + * The method POSTs to `/api/auth/signin` and returns the flow-step response + * or a synthesized completion response once the server has set the session + * cookie. * * **Redirect flow**: call with an optional `options` object (or no args). * Navigates to `/api/auth/signin` (which triggers a server redirect to the * IdP). */ const signIn = async (...args: any[]): Promise => { - // Embedded-flow path: second arg is a non-null object with `flowId`. + // Embedded-flow path: arg0 is a non-null object with `applicationId` (new flow) + // or `executionId` (continuing one). const arg0: unknown = args[0]; - const isEmbedded: boolean = typeof arg0 === 'object' && arg0 !== null && 'flowId' in arg0; + const isEmbedded: boolean = + typeof arg0 === 'object' && arg0 !== null && ('applicationId' in arg0 || 'executionId' in arg0); if (isEmbedded) { const payload: Record = arg0 as Record; @@ -57,9 +61,8 @@ export function useThunderID(): ThunderIDContext { // Flow complete — server has set the session cookie. Refresh the client // auth state so `useThunderID().isSignedIn` flips to true *immediately* // (without waiting for a full page reload). Then return a synthetic - // Complete response so `BaseSignIn` emits its `success` event - // and the wrapper component (``) drives navigation via - // `onSuccess`. + // Complete response so the `` container's `handleComplete` + // emits `success` and drives navigation via `afterSignInUrl`. // // `authData` is intentionally empty: the auth code / state were already // consumed server-side in `signin.post.ts`, so there is nothing to @@ -113,7 +116,7 @@ export function useThunderID(): ThunderIDContext { const payload: unknown = args[0]; // Embedded-flow path: arg0 is a non-null object with a `flowType` key - // (see `ThunderIDSignUp`'s `handleInitialize`/`handleOnSubmit`). + // (see `SignUp`'s `handleInitialize`/`handleOnSubmit`). const isEmbedded: boolean = typeof payload === 'object' && payload !== null && 'flowType' in payload; if (isEmbedded) { @@ -124,7 +127,7 @@ export function useThunderID(): ThunderIDContext { // Flow complete — the server route replies with `{ afterSignUpUrl }` // (no `flowStatus`). Synthesize one so `BaseSignUp`'s completion check - // (`response.flowStatus === Complete`) fires and ``'s + // (`response.flowStatus === Complete`) fires and ``'s // `handleComplete` drives the post-registration redirect. if (res.data?.afterSignUpUrl) { return {afterSignUpUrl: res.data.afterSignUpUrl, flowStatus: EmbeddedSignUpFlowStatus.Complete}; diff --git a/packages/nuxt/src/runtime/plugins/thunderid.ts b/packages/nuxt/src/runtime/plugins/thunderid.ts index 100c788e..4c71bc58 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 {AttributeSchema, UserProfile} from '@thunderid/node'; +import type {AttributeSchema, FlowMetadataResponse, 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, getUserSchemaStateKey} from '../utils/stateKeys'; +import {getAuthStateKey, getFlowMetaStateKey, getUserProfileStateKey, getUserSchemaStateKey} from '../utils/stateKeys'; import type {NuxtApp} from '#app'; import {defineNuxtPlugin, useState, useRequestEvent, useRuntimeConfig, navigateTo} from '#app'; @@ -64,12 +64,13 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { // counterpart is handled by the thunderid-ssr Nitro plugin; doing both // covers the two places a developer will actually look. if (import.meta.client && import.meta.dev) { - if (!publicConfig?.baseUrl || !publicConfig?.clientId) { + if (!publicConfig?.baseUrl || (!publicConfig?.clientId && !publicConfig?.applicationId)) { // eslint-disable-next-line no-console console.warn( - '[@thunderid/nuxt] Missing baseUrl or clientId. ' + - 'Set NUXT_PUBLIC_THUNDERID_BASE_URL and NUXT_PUBLIC_THUNDERID_CLIENT_ID, ' + - 'or configure `thunderid` in nuxt.config. Auth endpoints will not function until this is resolved.', + '[@thunderid/nuxt] Missing baseUrl, or both clientId and applicationId. ' + + 'Set NUXT_PUBLIC_THUNDERID_BASE_URL and either NUXT_PUBLIC_THUNDERID_CLIENT_ID (redirect flow) ' + + 'or NUXT_PUBLIC_THUNDERID_APPLICATION_ID (native flow), or configure `thunderid` in nuxt.config. ' + + 'Auth endpoints will not function until this is resolved.', ); } } @@ -92,10 +93,22 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { getUserSchemaStateKey(vendor), () => null, ); + // Seeded from `event.context[vendor].flowMeta` (written unconditionally by the thunderid-ssr + // Nitro plugin, regardless of sign-in state) so `FlowMetaProvider` can skip its own initial + // client-side fetch — see `runtime/components/ThunderIDRoot.ts`. + const flowMetaState: Ref = useState( + getFlowMetaStateKey(vendor), + () => null, + ); if (import.meta.server) { const event: H3Event | undefined = useRequestEvent(); - const ssr: ThunderIDSSRData | undefined = (event?.context as Record | undefined)?.[vendor]?.ssr; + const eventVendorContext: Record | undefined = (event?.context as Record | undefined)?.[ + vendor + ]; + const ssr: ThunderIDSSRData | undefined = eventVendorContext?.ssr; + + flowMetaState.value = (eventVendorContext?.flowMeta as FlowMetadataResponse | undefined) ?? null; if (ssr) { // Seed from the rich SSR payload written by the thunderid-ssr Nitro plugin. diff --git a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts index a9847da9..f55f5bf7 100644 --- a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts +++ b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts @@ -4,10 +4,12 @@ import { ThunderIDNodeClient, ThunderIDRuntimeError, + executeEmbeddedSignInFlow, extractUserClaimsFromIdToken, generateFlattenedUserProfile, getUsersMe, getUsersMeMeta, + isEmpty, resolveResourceEndpoint, updateMeProfile, type AttributeSchema, @@ -53,6 +55,7 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { clientSecret: config.clientSecret || undefined, enablePKCE: true, endpoints: config.endpoints, + flowSecret: config.flowSecret || undefined, scopes: config.scopes || ['openid', 'profile'], tokenRequest: config.tokenRequest, } as AuthClientConfig; @@ -92,8 +95,32 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { ); } - override signIn(...args: any[]): Promise { + override async signIn(...args: any[]): Promise { const arg0: unknown = args[0]; + const arg1: unknown = args[1]; + + // An embedded (app-native) sign-in flow payload initiates or continues a `POST /flow/execute` + // step (identified by `applicationId` for a new flow or `executionId` to continue one). This + // is distinct from the OAuth authorization_code exchange handled below, which is used once a + // redirect-preceded flow completes and returns an authorization code. + const isEmbeddedFlowPayload: boolean = + typeof arg0 === 'object' && + arg0 !== null && + !isEmpty(arg0 as Record) && + ('executionId' in (arg0 as object) || 'applicationId' in (arg0 as object)); + + if (isEmbeddedFlowPayload) { + const request: {flowSecret?: string; url?: string} = + typeof arg1 === 'object' && arg1 !== null ? (arg1 as {flowSecret?: string; url?: string}) : {}; + const configData: ThunderIDNuxtConfig = (await this.getStorageManager().getConfigData()) as ThunderIDNuxtConfig; + + return executeEmbeddedSignInFlow({ + baseUrl: configData?.baseUrl, + flowSecret: request.flowSecret ?? configData?.flowSecret, + payload: arg0, + url: resolveResourceEndpoint('flowExecute', configData, request.url), + }); + } if (typeof arg0 === 'object' && arg0 !== null && ('code' in arg0 || 'state' in arg0)) { const payload: {code?: unknown; session_state?: unknown; state?: unknown} = arg0 as { diff --git a/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts b/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts index 2f511803..91f2b9a4 100644 --- a/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts +++ b/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts @@ -1,7 +1,13 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {VendorConstants} from '@thunderid/node'; +import { + FlowMetadataResponse, + FlowMetaType, + getFlowMeta, + resolveResourceEndpoint, + VendorConstants, +} from '@thunderid/node'; import {getRequestURL, type H3Event} from 'h3'; import {defineNitroPlugin} from 'nitropack/runtime'; import type {ThunderIDAuthState, ThunderIDNuxtConfig, ThunderIDSSRData} from '../../types'; @@ -52,10 +58,18 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { const publicConfig: ThunderIDNuxtConfig = config.public.thunderid as ThunderIDNuxtConfig; const privateConfig: typeof config.thunderid = config.thunderid; - if (!publicConfig?.baseUrl || !publicConfig?.clientId) { + if (!publicConfig?.baseUrl) { + log.error('Missing required config: baseUrl. Set NUXT_PUBLIC_THUNDERID_BASE_URL.'); + return; + } + + // clientId (redirect flow) and applicationId (native flow) are mutually exclusive — + // exactly one is required depending on which flow this app uses. + if (!publicConfig?.clientId && !publicConfig?.applicationId) { log.error( - 'Missing required config: baseUrl and clientId. ' + - 'Set NUXT_PUBLIC_THUNDERID_BASE_URL and NUXT_PUBLIC_THUNDERID_CLIENT_ID.', + 'Missing required config: clientId or applicationId. ' + + 'Set NUXT_PUBLIC_THUNDERID_CLIENT_ID for the redirect flow, or ' + + 'NUXT_PUBLIC_THUNDERID_APPLICATION_ID for the native flow.', ); return; } @@ -85,6 +99,7 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { baseUrl: publicConfig.baseUrl, clientId: publicConfig.clientId, clientSecret: privateConfig?.clientSecret || undefined, + flowSecret: privateConfig?.flowSecret || undefined, endpoints: publicConfig.endpoints, platform: publicConfig.platform, scopes: publicConfig.scopes || ['openid', 'profile'], @@ -111,13 +126,27 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { // Nuxt plugin (`runtime/plugins/thunderid.ts`) reads from. const vendor: string = publicConfig?.vendor ?? VendorConstants.VENDOR_PREFIX; + // Flow metadata (design config + i18n bundle) doesn't depend on sign-in state, so it's + // fetched unconditionally and seeded via `useState` — same as `ThunderIDServerProvider` in + // `@thunderid/nextjs` — so `FlowMetaProvider` can skip its own initial client-side fetch. + let flowMeta: FlowMetadataResponse | null = null; + try { + flowMeta = await getFlowMeta({ + baseUrl: publicConfig?.baseUrl, + url: resolveResourceEndpoint('flowMeta', publicConfig), + ...(publicConfig?.applicationId ? {id: publicConfig.applicationId, type: FlowMetaType.App} : {}), + }); + } catch (err) { + log.warn('Failed to fetch flow metadata:', err); + } + const session: Awaited> = await verifyAndRehydrateSession( event, sessionSecret, ); if (!session) { const eventContext: Record = event.context; - eventContext[vendor] = {isSignedIn: false, session: null}; + eventContext[vendor] = {flowMeta, isSignedIn: false, session: null}; return; } @@ -177,7 +206,7 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { }; const eventContext: Record = event.context; - eventContext[vendor] = {isSignedIn: true, session, ssr: ssrData}; + eventContext[vendor] = {flowMeta, isSignedIn: true, session, ssr: ssrData}; // Keep legacy __thunderidAuth in place so the existing Nuxt plugin // (Step 3) can be updated independently without a runtime gap. diff --git a/packages/nuxt/src/runtime/server/routes/auth/session/callback.post.ts b/packages/nuxt/src/runtime/server/routes/auth/session/callback.post.ts index e8664124..cd36d182 100644 --- a/packages/nuxt/src/runtime/server/routes/auth/session/callback.post.ts +++ b/packages/nuxt/src/runtime/server/routes/auth/session/callback.post.ts @@ -25,7 +25,7 @@ function isTokenResponse(value: unknown): value is TokenResponse { * POST /api/auth/callback * * Exchanges an authorization code for tokens and issues a session cookie. - * Called by the client-side `ThunderIDCallback` component after the IDP + * Called by the client-side `Callback` component after the IDP * redirects back with `?code=...&state=...`. * * Request body: diff --git a/packages/nuxt/src/runtime/server/routes/auth/session/meta.get.ts b/packages/nuxt/src/runtime/server/routes/auth/session/meta.get.ts new file mode 100644 index 00000000..98f33425 --- /dev/null +++ b/packages/nuxt/src/runtime/server/routes/auth/session/meta.get.ts @@ -0,0 +1,36 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {FlowMetaType, getFlowMeta, resolveResourceEndpoint} from '@thunderid/node'; +import type {AuthClientConfig, FlowMetadataResponse} from '@thunderid/node'; +import {defineEventHandler, getQuery} from 'h3'; +import type {H3Event} from 'h3'; +import ThunderIDNuxtClient from '../../../ThunderIDNuxtClient'; +import type {ThunderIDNuxtConfig} from '../../../types'; + +/** + * GET /api/auth/meta + * + * Fetches flow metadata (design config + i18n bundle) from the `GET /flow/meta` endpoint + * server-side, so `FlowMetaProvider` never calls the ThunderID server's `baseUrl` directly from + * the browser — that would be a cross-origin request requiring CORS to be configured on the + * ThunderID server. Routing it through this same-origin route instead avoids that requirement + * entirely, mirroring `getFlowMetaAction` in `@thunderid/nextjs`. + * + * Accepts an optional `?language=` query parameter, used by `FlowMetaProvider.switchLanguage()`. + */ +export default defineEventHandler(async (event: H3Event): Promise => { + const client: ThunderIDNuxtClient = ThunderIDNuxtClient.getInstance(); + const configData: AuthClientConfig = await client.getStorageManager().getConfigData(); + + const query: Record = getQuery(event); + const language: string | undefined = typeof query.language === 'string' ? query.language : undefined; + const applicationId: string | undefined = configData?.applicationId; + + return getFlowMeta({ + baseUrl: configData?.baseUrl, + url: resolveResourceEndpoint('flowMeta', configData), + ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), + ...(language ? {language} : {}), + }); +}); diff --git a/packages/nuxt/src/runtime/server/routes/auth/session/signin.post.ts b/packages/nuxt/src/runtime/server/routes/auth/session/signin.post.ts index 1e0dfa55..d4183e4e 100644 --- a/packages/nuxt/src/runtime/server/routes/auth/session/signin.post.ts +++ b/packages/nuxt/src/runtime/server/routes/auth/session/signin.post.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {EmbeddedSignInFlowStatus, generateSessionId, isEmpty} from '@thunderid/node'; -import type {TokenResponse} from '@thunderid/node'; +import type {IdToken, TokenResponse} from '@thunderid/node'; import {defineEventHandler, readBody, getCookie, setCookie, deleteCookie, createError} from 'h3'; import type {H3Event} from 'h3'; import ThunderIDNuxtClient from '../../../ThunderIDNuxtClient'; @@ -16,14 +16,6 @@ import { } from '../../../utils/session'; import {useRuntimeConfig} from '#imports'; -function isTokenResponse(value: unknown): value is TokenResponse { - return ( - typeof value === 'object' && - value !== null && - ('accessToken' in value || 'idToken' in value || 'refreshToken' in value) - ); -} - /** * POST /api/auth/signin * @@ -80,8 +72,11 @@ export default defineEventHandler(async (event: H3Event) => { const payload: Record = body?.payload ?? {}; const request: Record = body?.request ?? {}; - // ── Initiate flow (no payload or empty payload) ──────────────────────────── - if (isEmpty(payload) || !('flowId' in payload)) { + // ── Initiate redirect-based sign-in (no payload) ──────────────────────────── + // An embedded (app-native) flow always sends a payload — either `{applicationId, flowType}` + // to start a new flow, or `{executionId, ...}` to continue one. Only a genuinely empty payload + // means the caller wants the hosted-page authorize URL for a redirect-based sign-in. + if (isEmpty(payload)) { try { const signInUrl: string = await client.getAuthorizeRequestUrl( {client_secret: '{{clientSecret}}', response_mode: 'direct'}, @@ -107,34 +102,33 @@ export default defineEventHandler(async (event: H3Event) => { }); } - // ── Flow complete — exchange code for tokens and issue session cookie ─────── + // ── Flow complete — establish the session from the flow's JWT assertion ──── + // A completed embedded flow returns a self-contained JWT `assertion` rather than an + // authorization code to exchange (mirrors how @thunderid/react and @thunderid/vue treat it: + // the assertion is used directly as the bearer session token). if ((response as {flowStatus?: unknown})?.flowStatus === EmbeddedSignInFlowStatus.Complete) { - const authData: {code?: string; session_state?: string; state?: string} = - (response as {authData?: {code?: string; session_state?: string; state?: string}})?.authData ?? {}; - const {code, state, session_state: sessionState} = authData; + const assertion: string | undefined = (response as {assertion?: string})?.assertion; - if (!code) { - throw createError({statusCode: 502, statusMessage: 'Authorization code missing from completed flow response.'}); + if (!assertion) { + throw createError({statusCode: 502, statusMessage: 'Flow completed without an assertion.'}); } - let tokenResponse: unknown; try { - tokenResponse = await client.signIn({code, session_state: sessionState, state}, {}, sessionId); - } catch (err: any) { - throw createError({ - statusCode: 502, - statusMessage: `Token exchange failed after embedded flow: ${err?.message ?? String(err)}`, - }); - } + const idToken: IdToken = await client.getDecodedIdToken(sessionId, assertion); + const iat: number = typeof idToken.iat === 'number' ? idToken.iat : Math.floor(Date.now() / 1000); + const exp: number = typeof idToken.exp === 'number' ? idToken.exp : iat + 3600; + const scope: string = typeof idToken.scope === 'string' ? idToken.scope : ''; + + const tokenResponse: TokenResponse = { + accessToken: assertion, + createdAt: iat, + expiresIn: String(Math.max(exp - iat, 0)), + idToken: assertion, + refreshToken: '', + scope, + tokenType: 'Bearer', + } as TokenResponse; - if (!isTokenResponse(tokenResponse)) { - throw createError({ - statusCode: 502, - statusMessage: 'Token exchange failed: Invalid token response from Identity Provider.', - }); - } - - try { await issueSessionCookie(event, sessionId, tokenResponse, sessionSecret); deleteCookie(event, getTempSessionCookieName(), getTempSessionCookieOptions()); } catch (err: any) { diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index cad8d513..b156d3a7 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -4,6 +4,7 @@ import type { AttributeSchema, AuthClientConfig, + FlowMetadataResponse, I18nPreferences, TokenEndpointAuthMethod, User, @@ -18,6 +19,12 @@ import type {JWTPayload} from 'jose'; export interface ThunderIDNuxtConfig extends AuthClientConfig { /** Secret for signing session JWTs (use THUNDERID_SESSION_SECRET env var) */ sessionSecret?: string; + /** + * Flow Secret for this app. Sent in the `Flow-Secret` request header to authenticate the app + * when an embedded (app-native) flow starts (use THUNDERID_FLOW_SECRET env var). Server-only, + * never exposed to the browser. + */ + flowSecret?: string; /** Platform identifier */ platform?: any; /** diff --git a/packages/nuxt/src/runtime/utils/stateKeys.ts b/packages/nuxt/src/runtime/utils/stateKeys.ts index 3a0b92c9..dad4e324 100644 --- a/packages/nuxt/src/runtime/utils/stateKeys.ts +++ b/packages/nuxt/src/runtime/utils/stateKeys.ts @@ -27,3 +27,10 @@ export const getUserProfileStateKey = (vendor?: string): string => `${getVendorP * Must stay in sync across the same three files as {@link getAuthStateKey}. */ export const getUserSchemaStateKey = (vendor?: string): string => `${getVendorPrefix(vendor)}:user-schema`; + +/** + * Shared `useState` key for the SSR-hydrated flow metadata (`FlowMetadataResponse | null`). + * + * Must stay in sync across the same three files as {@link getAuthStateKey}. + */ +export const getFlowMetaStateKey = (vendor?: string): string => `${getVendorPrefix(vendor)}:flow-meta`; diff --git a/packages/nuxt/tests/unit/signin-post.test.ts b/packages/nuxt/tests/unit/signin-post.test.ts index 9db075ed..e0cdd864 100644 --- a/packages/nuxt/tests/unit/signin-post.test.ts +++ b/packages/nuxt/tests/unit/signin-post.test.ts @@ -28,6 +28,7 @@ const mockClientInstance = vi.hoisted(() => ({ .fn<() => Promise>() .mockResolvedValue('https://localhost:8090/oauth2/authorize?code_challenge=x'), signIn: vi.fn<() => Promise>().mockResolvedValue(undefined), + getDecodedIdToken: vi.fn<() => Promise>().mockResolvedValue({iat: 1000, exp: 4600, scope: 'openid profile'}), })); // ── Module mocks ────────────────────────────────────────────────────────────── @@ -158,7 +159,7 @@ describe('POST /api/auth/signin', () => { }); describe('embedded flow completion (SUCCESS_COMPLETED)', () => { - it('exchanges code and issues session cookie on flow completion', async () => { + it('establishes the session from the assertion and issues a session cookie on flow completion', async () => { const flowPayload = { flowId: 'flow-abc', selectedAuthenticator: {authenticatorId: 'BasicAuthenticator'}, @@ -169,31 +170,31 @@ describe('POST /api/auth/signin', () => { }; const completedResponse = { flowStatus: 'COMPLETE', - authData: { - code: 'auth-code-xyz', - state: 'state-123', - session_state: 'sess-state-abc', - }, + assertion: 'assertion-jwt-xyz', }; - const tokenResponse = {accessToken: 'at-new', idToken: 'id-token'}; - // First signIn call → completed flow response; second → token response. - mockClientInstance.signIn.mockResolvedValueOnce(completedResponse).mockResolvedValueOnce(tokenResponse); + mockClientInstance.signIn.mockResolvedValueOnce(completedResponse); vi.mocked(readBody).mockResolvedValue({payload: flowPayload}); const result = await (signinHandler as any)(mockEvent); - expect(issueSessionCookie).toHaveBeenCalled(); + expect(mockClientInstance.getDecodedIdToken).toHaveBeenCalledWith(expect.any(String), 'assertion-jwt-xyz'); + expect(issueSessionCookie).toHaveBeenCalledWith( + mockEvent, + expect.any(String), + expect.objectContaining({accessToken: 'assertion-jwt-xyz', idToken: 'assertion-jwt-xyz'}), + expect.any(String), + ); expect(deleteCookie).toHaveBeenCalled(); expect(result).toEqual({data: {afterSignInUrl: '/dashboard'}, success: true}); }); - it('throws 502 when authorization code is missing from completed flow', async () => { + it('throws 502 when the completed flow response has no assertion', async () => { const flowPayload = {flowId: 'flow-abc', selectedAuthenticator: {authenticatorId: 'Basic'}, flowInputs: []}; - const completedNoCode = {flowStatus: 'COMPLETE', authData: {}}; + const completedNoAssertion = {flowStatus: 'COMPLETE'}; - mockClientInstance.signIn.mockResolvedValueOnce(completedNoCode); + mockClientInstance.signIn.mockResolvedValueOnce(completedNoAssertion); vi.mocked(readBody).mockResolvedValue({payload: flowPayload}); await expect((signinHandler as any)(mockEvent)).rejects.toMatchObject({ diff --git a/packages/nuxt/tests/unit/thunderid-root.test.ts b/packages/nuxt/tests/unit/thunderid-root.test.ts index 80e2cca9..0eb423d0 100644 --- a/packages/nuxt/tests/unit/thunderid-root.test.ts +++ b/packages/nuxt/tests/unit/thunderid-root.test.ts @@ -127,9 +127,9 @@ describe('ThunderIDRoot component', () => { expect(root.type).toBe(I18nProvider); const flowMeta = findByType(root, FlowMetaProvider); expect(flowMeta).not.toBeNull(); - // FlowMetaProvider defaults to V1 (`enabled: false`) — `useFlowMeta()` - // still resolves but the provider does not fetch v2 metadata. - expect(flowMeta!.props.enabled).toBe(false); + // FlowMetaProvider is always enabled — it fetches v2 flow metadata + // (theme/branding, server-side i18n translations) for `useFlowMeta()`. + expect(flowMeta!.props.enabled).toBe(true); const theme = findByType(root, ThemeProvider); expect(theme).not.toBeNull(); const flow = findByType(root, FlowProvider); diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index e5bcacb3..91f90ce9 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -23,7 +23,7 @@ import { PrefixOption, } from '@thunderid/browser'; import DOMPurify from 'dompurify'; -import {ChangeEvent, cloneElement, CSSProperties, ReactElement} from 'react'; +import {ChangeEvent, cloneElement, CSSProperties, FormEvent, ReactElement} from 'react'; import { ComponentRenderer, ComponentRenderContext, @@ -607,8 +607,17 @@ const createAuthComponentFromFlow = ( ) .filter(Boolean); + // The submit button's `type="submit"` (and pressing Enter in a field) would + // otherwise trigger the browser's native form submission — a GET request to + // the current URL with all field values (including passwords) as query params. + // Actual submission is handled entirely via `onClick`/`onSubmit` above. return ( -
+ ): void => event.preventDefault()} + > {blockComponents}
); diff --git a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx index 127d422f..b80de688 100644 --- a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx +++ b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx @@ -22,6 +22,17 @@ export interface FlowMetaProviderProps { */ enabled?: boolean; + /** + * Overrides how flow metadata is fetched, routing the request through a caller-supplied + * function (e.g. a Next.js Server Action) instead of the provider's default direct + * browser-to-`baseUrl` `fetch()`. Use this when the ThunderID server's `baseUrl` is a + * different origin than the app and CORS isn't (or can't be) configured for it — the + * override runs server-side, so the browser never talks to `baseUrl` directly. + * + * Called for both the initial fetch and `switchLanguage()`. + */ + fetchMeta?: (params: {applicationId?: string; language?: string}) => Promise; + /** * Flow metadata resolved ahead of time (e.g. fetched server-side during SSR) and used to seed * the provider's state. When present, the provider skips its own initial client-side fetch — @@ -57,6 +68,7 @@ export interface FlowMetaProviderProps { const FlowMetaProvider: FC> = ({ children, enabled = true, + fetchMeta, initialMeta = null, }: PropsWithChildren): ReactElement => { const {baseUrl, endpoints, applicationId, isInitialized} = useThunderID(); @@ -94,19 +106,21 @@ const FlowMetaProvider: FC> = ({ setError(null); try { - const result: FlowMetadataResponse = await getFlowMeta({ - baseUrl, - url: resolveResourceEndpoint('flowMeta', {endpoints}), - ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), - language: i18nContext?.currentLanguage, - }); + const result: FlowMetadataResponse = fetchMeta + ? await fetchMeta({applicationId, language: i18nContext?.currentLanguage}) + : await getFlowMeta({ + baseUrl, + url: resolveResourceEndpoint('flowMeta', {endpoints}), + ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), + language: i18nContext?.currentLanguage, + }); setMeta(result); } catch (err: unknown) { setError(err instanceof Error ? err : new Error(String(err))); } finally { setIsLoading(false); } - }, [enabled, baseUrl, endpoints, applicationId, isInitialized, i18nContext?.currentLanguage]); + }, [enabled, baseUrl, endpoints, applicationId, isInitialized, i18nContext?.currentLanguage, fetchMeta]); const switchLanguage: (language: string) => Promise = useCallback( async (language: string): Promise => { @@ -116,12 +130,14 @@ const FlowMetaProvider: FC> = ({ setError(null); try { - const result: FlowMetadataResponse = await getFlowMeta({ - baseUrl, - url: resolveResourceEndpoint('flowMeta', {endpoints}), - ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), - language, - }); + const result: FlowMetadataResponse = fetchMeta + ? await fetchMeta({applicationId, language}) + : await getFlowMeta({ + baseUrl, + url: resolveResourceEndpoint('flowMeta', {endpoints}), + ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), + language, + }); // Inject translations for the new language before switching if (result.i18n?.translations && i18nContext?.injectBundles) { @@ -145,7 +161,7 @@ const FlowMetaProvider: FC> = ({ setIsLoading(false); } }, - [enabled, baseUrl, endpoints, applicationId, i18nContext], + [enabled, baseUrl, endpoints, applicationId, i18nContext, fetchMeta], ); // After injectBundles + setPendingLanguage are batched and committed, this diff --git a/packages/react/src/utils/resolveTranslationsInObject.ts b/packages/react/src/utils/resolveTranslationsInObject.ts index 0bd9ca83..cad6f3c0 100644 --- a/packages/react/src/utils/resolveTranslationsInObject.ts +++ b/packages/react/src/utils/resolveTranslationsInObject.ts @@ -15,7 +15,7 @@ import {UseTranslation} from '../hooks/useTranslation'; export const resolveTranslationsInObject = >( obj: T, t: UseTranslation['t'], - properties: string[] = ['label', 'placeholder', 'text', 'title', 'subtitle'], + properties: string[] = ['label', 'placeholder', 'text', 'title', 'subtitle', 'alt', 'src'], meta?: FlowMetadataResponse | null, ): T => { const resolved: T = {...obj}; diff --git a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts index beee11df..a665b792 100644 --- a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts +++ b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts @@ -10,6 +10,8 @@ import { EmbeddedFlowTextVariant, EmbeddedFlowEventType, resolveFlowTemplateLiterals, + resolveLogoUri, + ResolvedLogo, extractEmojiFromUri, isEmojiUri, ConsentPurposeData, @@ -75,6 +77,8 @@ const getFieldType = (variant: EmbeddedFlowComponentType): FieldType => { switch (variant) { case EmbeddedFlowComponentType.EmailInput: return FieldType.Email; + case EmbeddedFlowComponentType.PhoneInput: + return FieldType.Tel; case EmbeddedFlowComponentType.PasswordInput: return FieldType.Password; case EmbeddedFlowComponentType.TextInput: @@ -160,7 +164,8 @@ const createAuthComponentFromFlow = ( switch (component.type) { case EmbeddedFlowComponentType.TextInput: case EmbeddedFlowComponentType.PasswordInput: - case EmbeddedFlowComponentType.EmailInput: { + case EmbeddedFlowComponentType.EmailInput: + case EmbeddedFlowComponentType.PhoneInput: { const identifier: string = component.ref ?? ''; const value: string = formValues[identifier] || ''; const isTouched: boolean = touchedFields[identifier] || false; @@ -181,6 +186,35 @@ const createAuthComponentFromFlow = ( }); } + case EmbeddedFlowComponentType.OtpInput: { + const identifier: string = component.ref ?? ''; + const value: string = formValues[identifier] || ''; + const isTouched: boolean = touchedFields[identifier] || false; + const error: string | undefined = isTouched ? formErrors[identifier] : undefined; + + // The server reports the length and character set of the code it generated, so the field + // matches the OTP the user received. An older server omits both and the defaults apply. + const reportedLength: number = Number(options.additionalData?.['otpLength']); + const otpLength: number | undefined = + Number.isInteger(reportedLength) && reportedLength > 0 ? reportedLength : undefined; + const numericOnly: boolean = options.additionalData?.['otpNumericOnly'] !== 'false'; + + return createField({ + className: options.inputClassName, + error, + label: resolve(component.label) || '', + length: otpLength, + name: identifier, + numericOnly, + onBlur: () => options.onInputBlur?.(identifier), + onChange: (newValue: string) => onInputChange(identifier, newValue), + placeholder: resolve(component.placeholder) || '', + required: component.required || false, + type: FieldType.Otp, + value, + }); + } + case EmbeddedFlowComponentType.Action: { const actionId: string = component.id; const eventType: string = component.eventType || ''; @@ -367,7 +401,24 @@ const createAuthComponentFromFlow = ( ) .filter(Boolean); - return h('form', {id: component.id, key}, blockChildren); + // The submit button's `type="submit"` (and pressing Enter in a field) would + // otherwise trigger the browser's native form submission — a GET request to + // the current URL with all field values (including passwords) as query params. + // Actual submission is handled entirely via `onSubmit`/`handleClick` above. + return h( + 'form', + { + id: component.id, + key, + onSubmit: (event: Event): void => event.preventDefault(), + style: { + display: 'flex', + flexDirection: 'column', + gap: 'calc(var(--thunderid-spacing-unit) * 2)', + }, + }, + blockChildren, + ); } return null; } @@ -384,16 +435,45 @@ const createAuthComponentFromFlow = ( } case EmbeddedFlowComponentType.Image: { - const explicitHeight: string = resolve((component as any).height?.toString()); - const explicitWidth: string = resolve((component as any).width?.toString()); + // Bare numbers (e.g. "48") are valid HTML width/height attributes but are + // unit-less and ignored as CSS style properties — normalize to px. + const toCSSLength = (value: string): string => (/^\d+(\.\d+)?$/.test(value) ? `${value}px` : value); + const explicitHeight: string = toCSSLength(resolve((component as any).height?.toString())); + const explicitWidth: string = toCSSLength(resolve((component as any).width?.toString())); + const alt: string = resolve((component as any).alt) || resolve(component.label) || 'Image'; + const resolvedSrc: string = resolve((component as any).src); + // When only one dimension is explicit, let the other scale to preserve aspect + // ratio instead of stretching it to the block-level fallback (100% width). + const height: string = explicitHeight || (explicitWidth ? 'auto' : options.inStack ? '50px' : 'auto'); + const width: string = explicitWidth || (explicitHeight ? 'auto' : options.inStack ? '50px' : '100%'); + + if (!resolvedSrc) { + return null; + } + + const resolvedLogo: ResolvedLogo = resolveLogoUri(resolvedSrc, alt); + + if (resolvedLogo.kind === 'emoji') { + return h( + 'span', + { + 'aria-label': alt, + key, + role: 'img', + style: {display: 'inline-block', fontSize: height !== 'auto' ? height : '2.5em', lineHeight: 1}, + }, + resolvedLogo.glyph, + ); + } + return h('img', { - alt: resolve((component as any).alt) || resolve(component.label) || 'Image', + alt, key, - src: resolve((component as any).src), + src: resolvedLogo.imgSrc, style: { - height: explicitHeight || (options.inStack ? '50px' : 'auto'), + height, objectFit: 'contain', - width: explicitWidth || (options.inStack ? '50px' : '100%'), + width, }, }); } diff --git a/packages/vue/src/components/auth/sign-in/BaseSignIn.css.ts b/packages/vue/src/components/auth/sign-in/BaseSignIn.css.ts new file mode 100644 index 00000000..a7195e05 --- /dev/null +++ b/packages/vue/src/components/auth/sign-in/BaseSignIn.css.ts @@ -0,0 +1,50 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Layout styles for the BaseSignIn composition. + * + * BEM block: `.thunderid-signin` + * + * Mirrors the layout constants in the React SDK's `BaseSignIn.styles.ts` + * (min-width, content gap, logo sizing) so the sign-in card renders with the + * same proportions across frameworks. + */ +const BASE_SIGN_IN_CSS = ` +/* ============================================================ + BaseSignIn + ============================================================ */ + +.thunderid-signin { + min-width: 420px; + margin: 0 auto; + font-family: var(--thunderid-typography-fontFamily, inherit); + border-radius: var(--thunderid-border-radius-large); +} + +.thunderid-signin__content { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) * 2); +} + +.thunderid-signin__content > img, +.thunderid-signin__content > span[role='img'] { + /* Flex column's default 'stretch' would otherwise force width: auto to fill + the container instead of shrinking to the image's intrinsic size. */ + align-self: center; + display: block; + margin: 0 0 calc(var(--thunderid-spacing-unit) * 1) 0; + border-radius: var(--thunderid-border-radius-small); +} + +.thunderid-signin__messages { + margin-top: calc(var(--thunderid-spacing-unit) * 2); +} + +.thunderid-signin__messages > * + * { + margin-top: calc(var(--thunderid-spacing-unit) * 1); +} +`; + +export default BASE_SIGN_IN_CSS; diff --git a/packages/vue/src/components/auth/sign-in/BaseSignIn.ts b/packages/vue/src/components/auth/sign-in/BaseSignIn.ts index 302e1d6c..64764ba5 100644 --- a/packages/vue/src/components/auth/sign-in/BaseSignIn.ts +++ b/packages/vue/src/components/auth/sign-in/BaseSignIn.ts @@ -128,6 +128,17 @@ const BaseSignIn: Component = defineComponent({ isLoading: {default: false, type: Boolean}, isTimeoutDisabled: {default: false, type: Boolean}, messageClassName: {default: '', type: String}, + // Declared explicitly (not just in the `BaseSignInProps` TS interface) because Vue treats any + // undeclared `onXxx`-named prop as a DOM event-listener fallthrough rather than a component + // prop. Without this, `props.onSubmit` is `undefined` at runtime — the callback instead falls + // through onto the rendered DOM tree via `{...attrs}` and fires when a native `submit` event + // bubbles up from a nested `
`, delivering the raw `SubmitEvent` instead of the flow payload. + onSubmit: { + default: undefined, + type: Function as PropType< + (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise + >, + }, size: { default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>, @@ -340,7 +351,11 @@ const BaseSignIn: Component = defineComponent({ validateForm, values: formValues.value, }; - return h('div', {class: containerClass, ...attrs}, slots['default'](renderProps)); + return h( + 'div', + {...attrs, class: [containerClass, (attrs as any).class].filter(Boolean).join(' ')}, + slots['default'](renderProps), + ); } // Loading state @@ -367,25 +382,29 @@ const BaseSignIn: Component = defineComponent({ (flowMessages as Ref<{message: string; type: string}[]>).value || []; const externalError: Error | null = props.error; - return h(Card, {class: containerClass, ...attrs, variant: props.variant}, () => [ - // Show errors and flow messages - (externalError || messages.length > 0) && - h( - 'div', - {class: [withVendorCSSClassPrefix('signin__messages'), props.messageClassName].filter(Boolean).join(' ')}, - [ - externalError && - h(Alert, {severity: 'error'}, () => h(Typography, {variant: 'body2'}, () => externalError.message)), - ...messages.map((msg: {message: string; type: string}, index: number) => - h(Alert, {key: index, severity: msg.type === 'error' ? 'error' : 'info'}, () => - h(Typography, {variant: 'body2'}, () => msg.message), + return h( + Card, + {...attrs, class: [containerClass, (attrs as any).class].filter(Boolean).join(' '), variant: props.variant}, + () => [ + // Show errors and flow messages + (externalError || messages.length > 0) && + h( + 'div', + {class: [withVendorCSSClassPrefix('signin__messages'), props.messageClassName].filter(Boolean).join(' ')}, + [ + externalError && + h(Alert, {severity: 'error'}, () => h(Typography, {variant: 'body2'}, () => externalError.message)), + ...messages.map((msg: {message: string; type: string}, index: number) => + h(Alert, {key: index, severity: msg.type === 'error' ? 'error' : 'info'}, () => + h(Typography, {variant: 'body2'}, () => msg.message), + ), ), - ), - ], - ), - // Render flow components - h('div', {class: withVendorCSSClassPrefix('signin__content')}, renderComponents()), - ]); + ], + ), + // Render flow components + h('div', {class: withVendorCSSClassPrefix('signin__content')}, renderComponents()), + ], + ); }; }, }); diff --git a/packages/vue/src/components/auth/sign-in/SignIn.ts b/packages/vue/src/components/auth/sign-in/SignIn.ts index ffb9ccfa..534454a8 100644 --- a/packages/vue/src/components/auth/sign-in/SignIn.ts +++ b/packages/vue/src/components/auth/sign-in/SignIn.ts @@ -116,6 +116,7 @@ const SignIn: Component = defineComponent({ const components: Ref = ref([]); const additionalData: Ref> = ref({}); const currentExecutionId: Ref = ref(null); + const challengeTokenRef: {current: string | null} = {current: null}; const isFlowInitialized: Ref = ref(false); const flowError: Ref = ref(null); const isSubmitting: Ref = ref(false); @@ -148,6 +149,7 @@ const SignIn: Component = defineComponent({ const clearFlowState = async (): Promise => { persistExecutionId(null); isFlowInitialized.value = false; + await setChallengeToken(null); const sm = getStorageManager(); if (sm) { await sm.removeHybridDataParameter('authId'); @@ -201,6 +203,26 @@ const SignIn: Component = defineComponent({ emit('error', error); }; + /** + * Updates challengeTokenRef immediately and persists via the provider's + * StorageManager so the token survives OAuth redirects. + */ + const setChallengeToken = async (challengeToken: string | null): Promise => { + challengeTokenRef.current = challengeToken; + try { + const sm = getStorageManager(); + if (sm) { + if (challengeToken) { + await sm.setTemporaryDataParameter('challengeToken', challengeToken); + } else { + await sm.removeTemporaryDataParameter('challengeToken'); + } + } + } catch { + // Ignore storage failures; the in-memory ref still has the current value. + } + }; + // ── Flow initialization ─────────────────────────────────────────────── const initializeFlow = async (): Promise => { @@ -265,6 +287,7 @@ const SignIn: Component = defineComponent({ } = normalizeFlowResponse(response, t, {resolveTranslations: false}, flowMeta.value); if (normalizedExecutionId && normalizedComponents) { + await setChallengeToken(response.challengeToken ?? null); persistExecutionId(normalizedExecutionId); components.value = normalizedComponents; additionalData.value = normalizedAdditionalData ?? {}; @@ -343,6 +366,7 @@ const SignIn: Component = defineComponent({ const response: EmbeddedSignInFlowResponse = (await signIn({ executionId: effectiveExecutionId, + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), ...payload, inputs: processedInputs, })) as EmbeddedSignInFlowResponse; @@ -383,6 +407,8 @@ const SignIn: Component = defineComponent({ return; } + await setChallengeToken(response.challengeToken ?? null); + const { executionId: normalizedExecutionId, components: normalizedComponents, @@ -411,6 +437,7 @@ const SignIn: Component = defineComponent({ await sm.removeHybridDataParameter('authId'); } cleanupOAuthUrlParams(); + await setChallengeToken(null); emit('success', { redirectUrl: finalRedirectUrl, @@ -560,6 +587,17 @@ const SignIn: Component = defineComponent({ onMounted(async () => { const urlParams: UrlParams = getUrlParams(); + // Restore any challenge token persisted before an OAuth redirect. + try { + const sm = getStorageManager(); + const tempData: any = await sm?.getTemporaryData?.(); + if (tempData?.challengeToken) { + challengeTokenRef.current = tempData.challengeToken as string; + } + } catch { + // Ignore — the flow will re-fetch a fresh challengeToken from the next response. + } + if (urlParams.authId) { const sm = getStorageManager(); if (sm) { diff --git a/packages/vue/src/components/factories/FieldFactory.ts b/packages/vue/src/components/factories/FieldFactory.ts index 83aa0321..c90fa7d4 100644 --- a/packages/vue/src/components/factories/FieldFactory.ts +++ b/packages/vue/src/components/factories/FieldFactory.ts @@ -137,6 +137,14 @@ export const createField = (config: FieldConfig): VNode => { type: 'email', } as Record); + case FieldType.Tel: + return h(TextField, { + ...commonProps, + autocomplete: 'tel', + 'onUpdate:modelValue': onChange, + type: 'tel', + } as Record); + case FieldType.Date: return h(DatePicker, { ...commonProps, diff --git a/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts b/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts index 4eef10a4..4d590fd3 100644 --- a/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts +++ b/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts @@ -464,7 +464,7 @@ const BaseInviteUser: Component = defineComponent({ 'div', { style: - 'display:flex;align-items:center;gap:0.5rem;padding:0.75rem;background:var(--thunder-color-background-secondary,#f5f5f5);border-radius:4px;word-break:break-all', + 'display:flex;align-items:center;gap:0.5rem;padding:0.75rem;background:var(--thunderid-color-background-secondary,#f5f5f5);border-radius:4px;word-break:break-all', }, [ h(Typography, {style: 'flex:1', variant: 'body2'}, () => inviteLink.value), diff --git a/packages/vue/src/components/presentation/language-switcher/LanguageSwitcher.css.ts b/packages/vue/src/components/presentation/language-switcher/LanguageSwitcher.css.ts index 8a5b17ef..d17f0e7a 100644 --- a/packages/vue/src/components/presentation/language-switcher/LanguageSwitcher.css.ts +++ b/packages/vue/src/components/presentation/language-switcher/LanguageSwitcher.css.ts @@ -35,28 +35,28 @@ const LANGUAGE_SWITCHER_CSS = ` .thunderid-language-switcher__trigger { display: inline-flex; align-items: center; - gap: calc(var(--thunder-spacing-unit) * 0.5); - padding: var(--thunder-dropdown-itemPaddingY) var(--thunder-dropdown-itemPaddingX); + gap: calc(var(--thunderid-spacing-unit) * 0.5); + padding: var(--thunderid-dropdown-itemPaddingY) var(--thunderid-dropdown-itemPaddingX); background: none; border: none; cursor: pointer; - color: var(--thunder-color-text-primary); - font-family: var(--thunder-typography-fontFamily); - font-size: var(--thunder-typography-fontSize-md); - border-radius: var(--thunder-dropdown-borderRadius); - transition: background-color var(--thunder-transition-fast); + color: var(--thunderid-color-text-primary); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-typography-fontSize-md); + border-radius: var(--thunderid-dropdown-borderRadius); + transition: background-color var(--thunderid-transition-fast); white-space: nowrap; box-sizing: border-box; } .thunderid-language-switcher__trigger:hover { - background-color: var(--thunder-color-action-hover); + background-color: var(--thunderid-color-action-hover); } .thunderid-language-switcher__trigger:focus-visible { outline: none; - box-shadow: inset 0 0 0 var(--thunder-focus-ring-width) var(--thunder-focus-ring-color); - border-radius: var(--thunder-dropdown-borderRadius); + box-shadow: inset 0 0 0 var(--thunderid-focus-ring-width) var(--thunderid-focus-ring-color); + border-radius: var(--thunderid-dropdown-borderRadius); } .thunderid-language-switcher__trigger-label { @@ -67,18 +67,18 @@ const LANGUAGE_SWITCHER_CSS = ` .thunderid-language-switcher__dropdown { position: absolute; - top: calc(100% + calc(var(--thunder-spacing-unit) * 0.5)); + top: calc(100% + calc(var(--thunderid-spacing-unit) * 0.5)); right: 0; z-index: 1000; - background-color: var(--thunder-color-background-surface); - border: 1px solid var(--thunder-color-border); - border-radius: var(--thunder-dropdown-borderRadius); - box-shadow: var(--thunder-dropdown-shadow); + background-color: var(--thunderid-color-background-surface); + border: 1px solid var(--thunderid-color-border); + border-radius: var(--thunderid-dropdown-borderRadius); + box-shadow: var(--thunderid-dropdown-shadow); overflow: hidden; min-width: 130px; display: flex; flex-direction: column; - padding: calc(var(--thunder-spacing-unit) * 0.5) 0; + padding: calc(var(--thunderid-spacing-unit) * 0.5) 0; } /* Items ----------------------------------------------------- */ @@ -87,30 +87,30 @@ const LANGUAGE_SWITCHER_CSS = ` display: flex; align-items: center; width: 100%; - padding: var(--thunder-dropdown-itemPaddingY) var(--thunder-dropdown-itemPaddingX); + padding: var(--thunderid-dropdown-itemPaddingY) var(--thunderid-dropdown-itemPaddingX); background: none; border: none; cursor: pointer; text-align: left; - font-family: var(--thunder-typography-fontFamily); - font-size: var(--thunder-typography-fontSize-md); - color: var(--thunder-color-text-primary); - transition: background-color var(--thunder-transition-fast); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-typography-fontSize-md); + color: var(--thunderid-color-text-primary); + transition: background-color var(--thunderid-transition-fast); box-sizing: border-box; } .thunderid-language-switcher__item:hover { - background-color: var(--thunder-color-action-hover); + background-color: var(--thunderid-color-action-hover); } .thunderid-language-switcher__item--active { - background-color: var(--thunder-color-action-selected); - color: var(--thunder-color-primary-main); - font-weight: var(--thunder-typography-fontWeight-medium); + background-color: var(--thunderid-color-action-selected); + color: var(--thunderid-color-primary-main); + font-weight: var(--thunderid-typography-fontWeight-medium); } .thunderid-language-switcher__item--active:hover { - background-color: var(--thunder-color-action-focus); + background-color: var(--thunderid-color-action-focus); } `; 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 0fb7bc03..31e77825 100644 --- a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts +++ b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts @@ -35,7 +35,7 @@ const USER_DROPDOWN_CSS = ` .thunderid-user-dropdown { position: relative; display: inline-block; - font-family: var(--thunder-typography-fontFamily); + font-family: var(--thunderid-typography-fontFamily); } /* ── Trigger ─────────────────────────────────────────────────── */ @@ -43,32 +43,32 @@ const USER_DROPDOWN_CSS = ` .thunderid-user-dropdown__trigger { display: inline-flex; align-items: center; - gap: calc(var(--thunder-spacing-unit) * 0.5); + gap: calc(var(--thunderid-spacing-unit) * 0.5); padding: 3px; background: none; border: 2px solid transparent; - border-radius: var(--thunder-border-radius-full); + border-radius: var(--thunderid-border-radius-full); cursor: pointer; - color: var(--thunder-color-text-primary); + color: var(--thunderid-color-text-primary); transition: - border-color var(--thunder-transition-fast), - box-shadow var(--thunder-transition-fast); + border-color var(--thunderid-transition-fast), + box-shadow var(--thunderid-transition-fast); box-sizing: border-box; outline: none; } .thunderid-user-dropdown__trigger:hover { - border-color: var(--thunder-color-primary-main); + border-color: var(--thunderid-color-primary-main); } .thunderid-user-dropdown__trigger--open { - border-color: var(--thunder-color-primary-main); - box-shadow: 0 0 0 3px var(--thunder-focus-ring-color); + border-color: var(--thunderid-color-primary-main); + box-shadow: 0 0 0 3px var(--thunderid-focus-ring-color); } .thunderid-user-dropdown__trigger:focus-visible { - border-color: var(--thunder-color-primary-main); - box-shadow: 0 0 0 var(--thunder-focus-ring-width) var(--thunder-focus-ring-color); + border-color: var(--thunderid-color-primary-main); + box-shadow: 0 0 0 var(--thunderid-focus-ring-width) var(--thunderid-focus-ring-color); } /* ── Trigger avatar ──────────────────────────────────────────── */ @@ -84,8 +84,8 @@ const USER_DROPDOWN_CSS = ` border-radius: 50%; color: #ffffff; flex-shrink: 0; - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-semibold); + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-semibold); line-height: 1; user-select: none; pointer-events: none; @@ -103,7 +103,7 @@ img.thunderid-user-dropdown__avatar { height: 28px; max-width: 28px; max-height: 28px; - font-size: var(--thunder-typography-fontSize-xs); + font-size: var(--thunderid-typography-fontSize-xs); } /* lg — 38 px */ @@ -112,7 +112,7 @@ img.thunderid-user-dropdown__avatar { height: 38px; max-width: 38px; max-height: 38px; - font-size: var(--thunder-typography-fontSize-md); + font-size: var(--thunderid-typography-fontSize-md); } /* ── Chevron ─────────────────────────────────────────────────── */ @@ -120,9 +120,9 @@ img.thunderid-user-dropdown__avatar { .thunderid-user-dropdown__chevron { display: inline-flex; align-items: center; - color: var(--thunder-color-text-secondary); - transition: transform var(--thunder-transition-normal); - padding-right: calc(var(--thunder-spacing-unit) * 0.25); + color: var(--thunderid-color-text-secondary); + transition: transform var(--thunderid-transition-normal); + padding-right: calc(var(--thunderid-spacing-unit) * 0.25); } .thunderid-user-dropdown__trigger--open .thunderid-user-dropdown__chevron { @@ -133,18 +133,18 @@ img.thunderid-user-dropdown__avatar { .thunderid-user-dropdown__menu { position: absolute; - top: calc(100% + calc(var(--thunder-spacing-unit) * 0.75)); + top: calc(100% + calc(var(--thunderid-spacing-unit) * 0.75)); right: 0; z-index: 1000; - background-color: var(--thunder-color-background-surface); - border: 1px solid var(--thunder-color-border); - border-radius: var(--thunder-dropdown-borderRadius); - box-shadow: var(--thunder-dropdown-shadow); + background-color: var(--thunderid-color-background-surface); + border: 1px solid var(--thunderid-color-border); + border-radius: var(--thunderid-dropdown-borderRadius); + box-shadow: var(--thunderid-dropdown-shadow); overflow: hidden; min-width: 220px; display: flex; flex-direction: column; - animation: thunderid-dropdown-enter var(--thunder-transition-fast) ease; + animation: thunderid-dropdown-enter var(--thunderid-transition-fast) ease; } /* Alignment */ @@ -161,19 +161,19 @@ img.thunderid-user-dropdown__avatar { } .thunderid-user-dropdown__menu--size-sm .thunderid-user-dropdown__menu-header { - padding: calc(var(--thunder-spacing-unit) * 1.25) calc(var(--thunder-spacing-unit) * 1.5); - gap: calc(var(--thunder-spacing-unit) * 1); + padding: calc(var(--thunderid-spacing-unit) * 1.25) calc(var(--thunderid-spacing-unit) * 1.5); + gap: calc(var(--thunderid-spacing-unit) * 1); } .thunderid-user-dropdown__menu--size-sm .thunderid-user-dropdown__menu-header-avatar { width: 30px; height: 30px; - font-size: var(--thunder-typography-fontSize-sm); + font-size: var(--thunderid-typography-fontSize-sm); } .thunderid-user-dropdown__menu--size-sm .thunderid-user-dropdown__item { - padding: calc(var(--thunder-spacing-unit) * 0.75) calc(var(--thunder-spacing-unit) * 1.5); - font-size: var(--thunder-typography-fontSize-xs); + padding: calc(var(--thunderid-spacing-unit) * 0.75) calc(var(--thunderid-spacing-unit) * 1.5); + font-size: var(--thunderid-typography-fontSize-xs); } /* Size: lg */ @@ -183,19 +183,19 @@ img.thunderid-user-dropdown__avatar { } .thunderid-user-dropdown__menu--size-lg .thunderid-user-dropdown__menu-header { - padding: calc(var(--thunder-spacing-unit) * 2) calc(var(--thunder-spacing-unit) * 2); - gap: calc(var(--thunder-spacing-unit) * 1.5); + padding: calc(var(--thunderid-spacing-unit) * 2) calc(var(--thunderid-spacing-unit) * 2); + gap: calc(var(--thunderid-spacing-unit) * 1.5); } .thunderid-user-dropdown__menu--size-lg .thunderid-user-dropdown__menu-header-avatar { width: 42px; height: 42px; - font-size: var(--thunder-typography-fontSize-lg); + font-size: var(--thunderid-typography-fontSize-lg); } .thunderid-user-dropdown__menu--size-lg .thunderid-user-dropdown__item { - padding: calc(var(--thunder-spacing-unit) * 1.25) calc(var(--thunder-spacing-unit) * 2); - font-size: var(--thunder-typography-fontSize-md); + padding: calc(var(--thunderid-spacing-unit) * 1.25) calc(var(--thunderid-spacing-unit) * 2); + font-size: var(--thunderid-typography-fontSize-md); } @keyframes thunderid-dropdown-enter { @@ -214,8 +214,8 @@ img.thunderid-user-dropdown__avatar { .thunderid-user-dropdown__menu-header { display: flex; align-items: center; - gap: calc(var(--thunder-spacing-unit) * 1.25); - padding: calc(var(--thunder-spacing-unit) * 1.5) calc(var(--thunder-spacing-unit) * 1.75); + gap: calc(var(--thunderid-spacing-unit) * 1.25); + padding: calc(var(--thunderid-spacing-unit) * 1.5) calc(var(--thunderid-spacing-unit) * 1.75); } .thunderid-user-dropdown__menu-header-avatar { @@ -227,8 +227,8 @@ img.thunderid-user-dropdown__avatar { border-radius: 50%; color: #ffffff; flex-shrink: 0; - font-size: var(--thunder-typography-fontSize-md); - font-weight: var(--thunder-typography-fontWeight-semibold); + font-size: var(--thunderid-typography-fontSize-md); + font-weight: var(--thunderid-typography-fontWeight-semibold); line-height: 1; user-select: none; object-fit: cover; @@ -247,30 +247,30 @@ img.thunderid-user-dropdown__menu-header-avatar { } .thunderid-user-dropdown__menu-header-name { - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-semibold); - color: var(--thunder-color-text-primary); + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-semibold); + color: var(--thunderid-color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: var(--thunder-typography-lineHeight-tight); + line-height: var(--thunderid-typography-lineHeight-tight); } .thunderid-user-dropdown__menu-header-subtitle { - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-text-secondary); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: var(--thunder-typography-lineHeight-normal); + line-height: var(--thunderid-typography-lineHeight-normal); } /* ── Menu divider ────────────────────────────────────────────── */ .thunderid-user-dropdown__menu-divider { height: 1px; - background-color: var(--thunder-color-border); - margin: calc(var(--thunder-spacing-unit) * 0.5) 0; + background-color: var(--thunderid-color-border); + margin: calc(var(--thunderid-spacing-unit) * 0.5) 0; flex-shrink: 0; } @@ -279,37 +279,37 @@ img.thunderid-user-dropdown__menu-header-avatar { .thunderid-user-dropdown__item { display: flex; align-items: center; - gap: calc(var(--thunder-spacing-unit) * 1); + gap: calc(var(--thunderid-spacing-unit) * 1); width: 100%; - padding: calc(var(--thunder-spacing-unit) * 1) calc(var(--thunder-spacing-unit) * 1.75); + padding: calc(var(--thunderid-spacing-unit) * 1) calc(var(--thunderid-spacing-unit) * 1.75); background: none; border: none; cursor: pointer; text-align: left; - font-family: var(--thunder-typography-fontFamily); - font-size: var(--thunder-typography-fontSize-sm); - color: var(--thunder-color-text-primary); - transition: background-color var(--thunder-transition-fast); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-typography-fontSize-sm); + color: var(--thunderid-color-text-primary); + transition: background-color var(--thunderid-transition-fast); box-sizing: border-box; } .thunderid-user-dropdown__item:hover { - background-color: var(--thunder-color-action-hover); + background-color: var(--thunderid-color-action-hover); } .thunderid-user-dropdown__item:focus-visible { outline: none; - background-color: var(--thunder-color-action-focus); + background-color: var(--thunderid-color-action-focus); } /* Danger variant (sign-out) */ .thunderid-user-dropdown__item--danger { - color: var(--thunder-color-error-main); + color: var(--thunderid-color-error-main); } .thunderid-user-dropdown__item--danger:hover { - background-color: var(--thunder-color-error-light); + background-color: var(--thunderid-color-error-light); } /* ── Modal overlay ───────────────────────────────────────────── */ @@ -324,7 +324,7 @@ img.thunderid-user-dropdown__menu-header-avatar { z-index: 9999; backdrop-filter: blur(3px); box-sizing: border-box; - animation: thunderid-overlay-enter var(--thunder-transition-fast) ease; + animation: thunderid-overlay-enter var(--thunderid-transition-fast) ease; } @keyframes thunderid-overlay-enter { @@ -335,18 +335,18 @@ img.thunderid-user-dropdown__menu-header-avatar { /* ── Modal content ───────────────────────────────────────────── */ .thunderid-user-dropdown__modal-content { - background: var(--thunder-color-background-surface); - border-radius: var(--thunder-border-radius-large); - box-shadow: var(--thunder-shadow-large); + background: var(--thunderid-color-background-surface); + border-radius: var(--thunderid-border-radius-large); + box-shadow: var(--thunderid-shadow-large); max-width: 640px; width: 92%; max-height: 90vh; overflow-y: auto; position: relative; margin: auto; - padding: calc(var(--thunder-spacing-unit) * 3); + padding: calc(var(--thunderid-spacing-unit) * 3); box-sizing: border-box; - animation: thunderid-modal-enter var(--thunder-transition-normal) ease; + animation: thunderid-modal-enter var(--thunderid-transition-normal) ease; } @keyframes thunderid-modal-enter { @@ -364,32 +364,32 @@ img.thunderid-user-dropdown__menu-header-avatar { .thunderid-user-dropdown__modal-close { position: absolute; - top: calc(var(--thunder-spacing-unit) * 1.25); - right: calc(var(--thunder-spacing-unit) * 1.25); + top: calc(var(--thunderid-spacing-unit) * 1.25); + right: calc(var(--thunderid-spacing-unit) * 1.25); background: none; border: none; cursor: pointer; - color: var(--thunder-color-text-secondary); + color: var(--thunderid-color-text-secondary); display: flex; align-items: center; justify-content: center; - padding: calc(var(--thunder-spacing-unit) * 0.625); - border-radius: var(--thunder-border-radius-small); + padding: calc(var(--thunderid-spacing-unit) * 0.625); + border-radius: var(--thunderid-border-radius-small); z-index: 10001; transition: - color var(--thunder-transition-fast), - background-color var(--thunder-transition-fast); + color var(--thunderid-transition-fast), + background-color var(--thunderid-transition-fast); line-height: 0; } .thunderid-user-dropdown__modal-close:hover { - color: var(--thunder-color-text-primary); - background-color: var(--thunder-color-action-hover); + color: var(--thunderid-color-text-primary); + background-color: var(--thunderid-color-action-hover); } .thunderid-user-dropdown__modal-close:focus-visible { outline: none; - box-shadow: 0 0 0 var(--thunder-focus-ring-width) var(--thunder-focus-ring-color); + box-shadow: 0 0 0 var(--thunderid-focus-ring-width) var(--thunderid-focus-ring-color); } `; 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 7836c162..8889cf70 100644 --- a/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts @@ -13,13 +13,13 @@ const USER_PROFILE_CSS = ` .thunderid-user-profile { display: flex; flex-direction: column; - padding: calc(var(--thunder-spacing-unit) * 4); + padding: calc(var(--thunderid-spacing-unit) * 4); width: 100%; max-width: 600px; margin: 0 auto; - font-family: var(--thunder-typography-fontFamily); - background: var(--thunder-color-background-surface); - border-radius: var(--thunder-border-radius-large, 8px); + font-family: var(--thunderid-typography-fontFamily); + background: var(--thunderid-color-background-surface); + border-radius: var(--thunderid-border-radius-large, 8px); box-sizing: border-box; } @@ -29,10 +29,10 @@ const USER_PROFILE_CSS = ` display: flex; flex-direction: column; 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); + gap: calc(var(--thunderid-spacing-unit) * 1); + padding-bottom: calc(var(--thunderid-spacing-unit) * 2); + margin-bottom: calc(var(--thunderid-spacing-unit) * 2); + border-bottom: 1px solid var(--thunderid-color-border); } .thunderid-user-profile__avatar-wrapper { @@ -43,6 +43,10 @@ const USER_PROFILE_CSS = ` .thunderid-user-profile__avatar { border-radius: 50%; object-fit: cover; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } img.thunderid-user-profile__avatar { @@ -50,11 +54,6 @@ img.thunderid-user-profile__avatar { object-fit: cover; width: 70px; height: 70px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; } .thunderid-user-profile__avatar--sm { @@ -86,7 +85,7 @@ img.thunderid-user-profile__avatar { .thunderid-user-profile__avatar-initials { color: #ffffff; - font-weight: var(--thunder-typography-fontWeight-semibold, 600); + font-weight: var(--thunderid-typography-fontWeight-semibold, 600); line-height: 1; letter-spacing: 0.02em; pointer-events: none; @@ -97,35 +96,35 @@ img.thunderid-user-profile__avatar { display: flex; flex-direction: column; align-items: flex-start; - margin-top: calc(var(--thunder-spacing-unit) * 0.5); + margin-top: calc(var(--thunderid-spacing-unit) * 0.5); } .thunderid-user-profile__hero-name { - font-size: var(--thunder-typography-fontSize-xl, 1.5rem); - font-weight: var(--thunder-typography-fontWeight-semibold, 600); - color: var(--thunder-color-text-primary); + font-size: var(--thunderid-typography-fontSize-xl, 1.5rem); + font-weight: var(--thunderid-typography-fontWeight-semibold, 600); + color: var(--thunderid-color-text-primary); margin: 0; - line-height: var(--thunder-typography-lineHeight-tight, 1.2); + line-height: var(--thunderid-typography-lineHeight-tight, 1.2); } .thunderid-user-profile__hero-subtitle { - font-size: var(--thunder-typography-fontSize-sm, 0.875rem); - color: var(--thunder-color-text-secondary); - margin-top: calc(var(--thunder-spacing-unit) * 0.5); - line-height: var(--thunder-typography-lineHeight-normal, 1.4); + font-size: var(--thunderid-typography-fontSize-sm, 0.875rem); + color: var(--thunderid-color-text-secondary); + margin-top: calc(var(--thunderid-spacing-unit) * 0.5); + line-height: var(--thunderid-typography-lineHeight-normal, 1.4); } /* ── Alerts & loading ────────────────────────────────────────── */ .thunderid-user-profile__error { - margin-bottom: calc(var(--thunder-spacing-unit) * 3); + margin-bottom: calc(var(--thunderid-spacing-unit) * 3); } .thunderid-user-profile__loading { display: flex; align-items: center; justify-content: center; - padding: calc(var(--thunder-spacing-unit) * 4) 0; + padding: calc(var(--thunderid-spacing-unit) * 4) 0; } /* ── Fields ──────────────────────────────────────────────────── */ @@ -139,8 +138,8 @@ img.thunderid-user-profile__avatar { 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); + padding: calc(var(--thunderid-spacing-unit) * 1.5) 0; + border-bottom: 1px solid var(--thunderid-color-border); min-height: 28px; box-sizing: border-box; } @@ -153,13 +152,13 @@ img.thunderid-user-profile__avatar { flex: 1; display: flex; align-items: center; - gap: var(--thunder-spacing-unit); + gap: var(--thunderid-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(--thunderid-typography-fontSize-sm, 0.875rem); + font-weight: var(--thunderid-typography-fontWeight-medium, 500); + color: var(--thunderid-color-text-secondary); width: 120px; flex-shrink: 0; line-height: 28px; @@ -167,11 +166,11 @@ img.thunderid-user-profile__avatar { } .thunderid-user-profile__field-value { - color: var(--thunder-color-text-primary); + color: var(--thunderid-color-text-primary); flex: 1; display: inline-block; align-items: center; - font-size: var(--thunder-typography-fontSize-sm, 0.875rem); + font-size: var(--thunderid-typography-fontSize-sm, 0.875rem); line-height: 28px; word-break: break-word; text-overflow: ellipsis; @@ -182,9 +181,9 @@ img.thunderid-user-profile__avatar { } .thunderid-user-profile__field-placeholder { - font-size: var(--thunder-typography-fontSize-sm, 0.875rem); + font-size: var(--thunderid-typography-fontSize-sm, 0.875rem); font-style: italic; - color: var(--thunder-color-text-secondary); + color: var(--thunderid-color-text-secondary); opacity: 0.7; cursor: pointer; text-decoration: underline; @@ -198,9 +197,9 @@ img.thunderid-user-profile__avatar { .thunderid-user-profile__field-actions { display: flex; - gap: calc(var(--thunder-spacing-unit) * 0.5); + gap: calc(var(--thunderid-spacing-unit) * 0.5); align-items: center; - margin-inline-start: calc(var(--thunder-spacing-unit) * 4); + margin-inline-start: calc(var(--thunderid-spacing-unit) * 4); } .thunderid-user-profile__field-edit-btn { @@ -210,12 +209,12 @@ img.thunderid-user-profile__avatar { background: transparent; border: none; cursor: pointer; - color: var(--thunder-color-text-secondary); + color: var(--thunderid-color-text-secondary); padding: 0; min-height: auto; opacity: 0.7; line-height: 0; - transition: opacity var(--thunder-transition-fast, 0.15s ease); + transition: opacity var(--thunderid-transition-fast, 0.15s ease); } .thunderid-user-profile__field-edit-btn:hover { @@ -227,27 +226,27 @@ img.thunderid-user-profile__avatar { flex: 1; display: flex; flex-direction: column; - gap: calc(var(--thunder-spacing-unit) * 0.5); + gap: calc(var(--thunderid-spacing-unit) * 0.5); } .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); + color: var(--thunderid-color-error, #d32f2f); + font-size: var(--thunderid-typography-fontSize-xs, 0.8rem); + font-weight: var(--thunderid-typography-fontWeight-medium, 500); + margin-top: calc(var(--thunderid-spacing-unit) * 0.5); } /* ── Footer ──────────────────────────────────────────────────── */ .thunderid-user-profile__footer { - padding-top: calc(var(--thunder-spacing-unit) * 2); - border-top: 1px solid var(--thunder-color-border); + padding-top: calc(var(--thunderid-spacing-unit) * 2); + border-top: 1px solid var(--thunderid-color-border); } /* ── Compact modifier ────────────────────────────────────────── */ .thunderid-user-profile--compact { - padding: calc(var(--thunder-spacing-unit) * 2); + padding: calc(var(--thunderid-spacing-unit) * 2); width: 100%; } `; diff --git a/packages/vue/src/components/primitives/Alert/Alert.css.ts b/packages/vue/src/components/primitives/Alert/Alert.css.ts index 93170600..d3509fc1 100644 --- a/packages/vue/src/components/primitives/Alert/Alert.css.ts +++ b/packages/vue/src/components/primitives/Alert/Alert.css.ts @@ -21,15 +21,15 @@ const ALERT_CSS = ` display: flex; align-items: flex-start; justify-content: space-between; - gap: calc(var(--thunder-spacing-unit) * 1); - padding: var(--thunder-alert-paddingY) var(--thunder-alert-paddingX); - border-radius: var(--thunder-alert-borderRadius); + gap: calc(var(--thunderid-spacing-unit) * 1); + padding: var(--thunderid-alert-paddingY) var(--thunderid-alert-paddingX); + border-radius: var(--thunderid-alert-borderRadius); border: 1px solid transparent; - font-family: var(--thunder-typography-fontFamily); - font-size: var(--thunder-typography-fontSize-sm); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-typography-fontSize-sm); box-sizing: border-box; width: 100%; - line-height: var(--thunder-typography-lineHeight-normal); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-alert__content { @@ -37,27 +37,27 @@ const ALERT_CSS = ` } .thunderid-alert--info { - background-color: var(--thunder-color-info-light); - border-color: var(--thunder-color-info-main); - color: var(--thunder-color-info-contrastText); + background-color: var(--thunderid-color-info-light); + border-color: var(--thunderid-color-info-main); + color: var(--thunderid-color-info-contrastText); } .thunderid-alert--success { - background-color: var(--thunder-color-success-light); - border-color: var(--thunder-color-success-main); - color: var(--thunder-color-success-contrastText); + background-color: var(--thunderid-color-success-light); + border-color: var(--thunderid-color-success-main); + color: var(--thunderid-color-success-contrastText); } .thunderid-alert--warning { - background-color: var(--thunder-color-warning-light); - border-color: var(--thunder-color-warning-main); - color: var(--thunder-color-warning-contrastText); + background-color: var(--thunderid-color-warning-light); + border-color: var(--thunderid-color-warning-main); + color: var(--thunderid-color-warning-contrastText); } .thunderid-alert--error { - background-color: var(--thunder-color-error-light); - border-color: var(--thunder-color-error-main); - color: var(--thunder-color-error-contrastText); + background-color: var(--thunderid-color-error-light); + border-color: var(--thunderid-color-error-main); + color: var(--thunderid-color-error-contrastText); } .thunderid-alert__dismiss { @@ -66,16 +66,16 @@ const ALERT_CSS = ` cursor: pointer; font-size: 1em; line-height: 0; - padding: calc(var(--thunder-spacing-unit) * 0.25); - border-radius: var(--thunder-border-radius-xs); + padding: calc(var(--thunderid-spacing-unit) * 0.25); + border-radius: var(--thunderid-border-radius-xs); color: inherit; opacity: 0.6; flex-shrink: 0; - transition: opacity var(--thunder-transition-fast), background-color var(--thunder-transition-fast); + transition: opacity var(--thunderid-transition-fast), background-color var(--thunderid-transition-fast); } .thunderid-alert__dismiss:hover { opacity: 1; - background-color: var(--thunder-color-action-hover); + background-color: var(--thunderid-color-action-hover); } `; diff --git a/packages/vue/src/components/primitives/Button/Button.css.ts b/packages/vue/src/components/primitives/Button/Button.css.ts index 6c082ada..69fa165f 100644 --- a/packages/vue/src/components/primitives/Button/Button.css.ts +++ b/packages/vue/src/components/primitives/Button/Button.css.ts @@ -27,11 +27,11 @@ const BUTTON_CSS = ` display: inline-flex; align-items: center; justify-content: center; - gap: calc(var(--thunder-spacing-unit) * 0.75); - border-radius: var(--thunder-button-borderRadius); - font-family: var(--thunder-typography-fontFamily); - font-weight: var(--thunder-button-fontWeight); - letter-spacing: var(--thunder-typography-letterSpacing-normal); + gap: calc(var(--thunderid-spacing-unit) * 0.75); + border-radius: var(--thunderid-button-borderRadius); + font-family: var(--thunderid-typography-fontFamily); + font-weight: var(--thunderid-button-fontWeight); + letter-spacing: var(--thunderid-typography-letterSpacing-normal); cursor: pointer; outline: none; text-decoration: none; @@ -40,12 +40,12 @@ const BUTTON_CSS = ` border-style: solid; box-sizing: border-box; transition: - background-color var(--thunder-transition-fast), - color var(--thunder-transition-fast), - border-color var(--thunder-transition-fast), - box-shadow var(--thunder-transition-fast), - opacity var(--thunder-transition-fast), - transform var(--thunder-transition-fast); + background-color var(--thunderid-transition-fast), + color var(--thunderid-transition-fast), + border-color var(--thunderid-transition-fast), + box-shadow var(--thunderid-transition-fast), + opacity var(--thunderid-transition-fast), + transform var(--thunderid-transition-fast); position: relative; vertical-align: middle; -webkit-font-smoothing: antialiased; @@ -55,27 +55,27 @@ const BUTTON_CSS = ` .thunderid-button:focus-visible { outline: none; - box-shadow: 0 0 0 var(--thunder-focus-ring-width) var(--thunder-focus-ring-color); + box-shadow: 0 0 0 var(--thunderid-focus-ring-width) var(--thunderid-focus-ring-color); } /* -- Sizes -- */ .thunderid-button--small { - padding: 0 var(--thunder-button-sm-paddingX); - font-size: var(--thunder-button-sm-fontSize); - height: var(--thunder-button-sm-height); + padding: 0 var(--thunderid-button-sm-paddingX); + font-size: var(--thunderid-button-sm-fontSize); + height: var(--thunderid-button-sm-height); } .thunderid-button--medium { - padding: 0 var(--thunder-button-md-paddingX); - font-size: var(--thunder-button-md-fontSize); - height: var(--thunder-button-md-height); + padding: 0 var(--thunderid-button-md-paddingX); + font-size: var(--thunderid-button-md-fontSize); + height: var(--thunderid-button-md-height); } .thunderid-button--large { - padding: 0 var(--thunder-button-lg-paddingX); - font-size: var(--thunder-button-lg-fontSize); - height: var(--thunder-button-lg-height); + padding: 0 var(--thunderid-button-lg-paddingX); + font-size: var(--thunderid-button-lg-fontSize); + height: var(--thunderid-button-lg-height); } /* -- Modifiers -- */ @@ -94,35 +94,35 @@ const BUTTON_CSS = ` /* -- Solid variants -- */ .thunderid-button--solid.thunderid-button--primary { - background-color: var(--thunder-color-primary-main); - color: var(--thunder-color-primary-contrastText); - border-color: var(--thunder-color-primary-main); + background-color: var(--thunderid-color-primary-main); + color: var(--thunderid-color-primary-contrastText); + border-color: var(--thunderid-color-primary-main); } .thunderid-button--solid.thunderid-button--primary:hover:not(:disabled) { - background-color: var(--thunder-color-primary-dark); - border-color: var(--thunder-color-primary-dark); + background-color: var(--thunderid-color-primary-dark); + border-color: var(--thunderid-color-primary-dark); } .thunderid-button--solid.thunderid-button--primary:active:not(:disabled) { transform: scale(0.98); } .thunderid-button--solid.thunderid-button--secondary { - background-color: var(--thunder-color-secondary-light); - color: var(--thunder-color-secondary-main); - border-color: var(--thunder-color-border); + background-color: var(--thunderid-color-secondary-light); + color: var(--thunderid-color-secondary-main); + border-color: var(--thunderid-color-border); } .thunderid-button--solid.thunderid-button--secondary:hover:not(:disabled) { - background-color: var(--thunder-color-border); - border-color: var(--thunder-color-border); + background-color: var(--thunderid-color-border); + border-color: var(--thunderid-color-border); } .thunderid-button--solid.thunderid-button--secondary:active:not(:disabled) { transform: scale(0.98); } .thunderid-button--solid.thunderid-button--danger { - background-color: var(--thunder-color-error-main); + background-color: var(--thunderid-color-error-main); color: #ffffff; - border-color: var(--thunder-color-error-main); + border-color: var(--thunderid-color-error-main); } .thunderid-button--solid.thunderid-button--danger:hover:not(:disabled) { filter: brightness(0.92); @@ -135,11 +135,11 @@ const BUTTON_CSS = ` .thunderid-button--outline.thunderid-button--primary { background-color: transparent; - color: var(--thunder-color-primary-main); - border-color: var(--thunder-color-primary-main); + color: var(--thunderid-color-primary-main); + border-color: var(--thunderid-color-primary-main); } .thunderid-button--outline.thunderid-button--primary:hover:not(:disabled) { - background-color: var(--thunder-color-primary-light); + background-color: var(--thunderid-color-primary-light); } .thunderid-button--outline.thunderid-button--primary:active:not(:disabled) { transform: scale(0.98); @@ -147,12 +147,12 @@ const BUTTON_CSS = ` .thunderid-button--outline.thunderid-button--secondary { background-color: transparent; - color: var(--thunder-color-secondary-main); - border-color: var(--thunder-color-border); + color: var(--thunderid-color-secondary-main); + border-color: var(--thunderid-color-border); } .thunderid-button--outline.thunderid-button--secondary:hover:not(:disabled) { - background-color: var(--thunder-color-secondary-light); - border-color: var(--thunder-color-secondary-main); + background-color: var(--thunderid-color-secondary-light); + border-color: var(--thunderid-color-secondary-main); } .thunderid-button--outline.thunderid-button--secondary:active:not(:disabled) { transform: scale(0.98); @@ -160,11 +160,11 @@ const BUTTON_CSS = ` .thunderid-button--outline.thunderid-button--danger { background-color: transparent; - color: var(--thunder-color-error-main); - border-color: var(--thunder-color-error-main); + color: var(--thunderid-color-error-main); + border-color: var(--thunderid-color-error-main); } .thunderid-button--outline.thunderid-button--danger:hover:not(:disabled) { - background-color: var(--thunder-color-error-light); + background-color: var(--thunderid-color-error-light); } .thunderid-button--outline.thunderid-button--danger:active:not(:disabled) { transform: scale(0.98); @@ -174,31 +174,31 @@ const BUTTON_CSS = ` .thunderid-button--ghost.thunderid-button--primary { background-color: transparent; - color: var(--thunder-color-primary-main); + color: var(--thunderid-color-primary-main); border-color: transparent; } .thunderid-button--ghost.thunderid-button--primary:hover:not(:disabled) { - background-color: var(--thunder-color-primary-light); + background-color: var(--thunderid-color-primary-light); border-color: transparent; } .thunderid-button--ghost.thunderid-button--secondary { background-color: transparent; - color: var(--thunder-color-secondary-main); + color: var(--thunderid-color-secondary-main); border-color: transparent; } .thunderid-button--ghost.thunderid-button--secondary:hover:not(:disabled) { - background-color: var(--thunder-color-action-hover); + background-color: var(--thunderid-color-action-hover); border-color: transparent; } .thunderid-button--ghost.thunderid-button--danger { background-color: transparent; - color: var(--thunder-color-error-main); + color: var(--thunderid-color-error-main); border-color: transparent; } .thunderid-button--ghost.thunderid-button--danger:hover:not(:disabled) { - background-color: var(--thunder-color-error-light); + background-color: var(--thunderid-color-error-light); border-color: transparent; } @@ -207,30 +207,30 @@ const BUTTON_CSS = ` .thunderid-button--text { border-color: transparent; background-color: transparent; - padding-left: calc(var(--thunder-spacing-unit) * 0.25); - padding-right: calc(var(--thunder-spacing-unit) * 0.25); + padding-left: calc(var(--thunderid-spacing-unit) * 0.25); + padding-right: calc(var(--thunderid-spacing-unit) * 0.25); } .thunderid-button--text.thunderid-button--primary { - color: var(--thunder-color-primary-main); + color: var(--thunderid-color-primary-main); } .thunderid-button--text.thunderid-button--primary:hover:not(:disabled) { - color: var(--thunder-color-primary-dark); + color: var(--thunderid-color-primary-dark); text-decoration: underline; text-underline-offset: 2px; } .thunderid-button--text.thunderid-button--secondary { - color: var(--thunder-color-secondary-main); + color: var(--thunderid-color-secondary-main); } .thunderid-button--text.thunderid-button--secondary:hover:not(:disabled) { - color: var(--thunder-color-text-primary); + color: var(--thunderid-color-text-primary); text-decoration: underline; text-underline-offset: 2px; } .thunderid-button--text.thunderid-button--danger { - color: var(--thunder-color-error-main); + color: var(--thunderid-color-error-main); } .thunderid-button--text.thunderid-button--danger:hover:not(:disabled) { text-decoration: underline; @@ -266,7 +266,7 @@ const BUTTON_CSS = ` border-right-color: transparent; border-radius: 50%; animation: thunder-spin 0.6s linear infinite; - margin-left: calc(var(--thunder-spacing-unit) * 0.5); + margin-left: calc(var(--thunderid-spacing-unit) * 0.5); } `; diff --git a/packages/vue/src/components/primitives/Card/Card.css.ts b/packages/vue/src/components/primitives/Card/Card.css.ts index c7fa2ae1..ef3c86e0 100644 --- a/packages/vue/src/components/primitives/Card/Card.css.ts +++ b/packages/vue/src/components/primitives/Card/Card.css.ts @@ -17,19 +17,19 @@ const CARD_CSS = ` ============================================================ */ .thunderid-card { - background-color: var(--thunder-color-background-surface); - border-radius: var(--thunder-card-borderRadius); - padding: var(--thunder-card-padding); + background-color: var(--thunderid-color-background-surface); + border-radius: var(--thunderid-card-borderRadius); + padding: var(--thunderid-card-padding); box-sizing: border-box; - transition: box-shadow var(--thunder-transition-normal); + transition: box-shadow var(--thunderid-transition-normal); } .thunderid-card--elevated { - box-shadow: var(--thunder-card-shadow); + box-shadow: var(--thunderid-card-shadow); } .thunderid-card--outlined { - border: 1px solid var(--thunder-card-borderColor); + border: 1px solid var(--thunderid-card-borderColor); } /* .thunderid-card--flat: no shadow or border */ diff --git a/packages/vue/src/components/primitives/Checkbox/Checkbox.css.ts b/packages/vue/src/components/primitives/Checkbox/Checkbox.css.ts index 69c93097..e3d58b4b 100644 --- a/packages/vue/src/components/primitives/Checkbox/Checkbox.css.ts +++ b/packages/vue/src/components/primitives/Checkbox/Checkbox.css.ts @@ -20,29 +20,29 @@ const CHECKBOX_CSS = ` .thunderid-checkbox { display: flex; flex-direction: column; - gap: calc(var(--thunder-spacing-unit) * 0.5); - font-family: var(--thunder-typography-fontFamily); + gap: calc(var(--thunderid-spacing-unit) * 0.5); + font-family: var(--thunderid-typography-fontFamily); } .thunderid-checkbox__wrapper { display: inline-flex; align-items: center; - gap: calc(var(--thunder-spacing-unit) * 0.75); + gap: calc(var(--thunderid-spacing-unit) * 0.75); cursor: pointer; user-select: none; } .thunderid-checkbox__input { - width: var(--thunder-checkbox-size); - height: var(--thunder-checkbox-size); + width: var(--thunderid-checkbox-size); + height: var(--thunderid-checkbox-size); cursor: pointer; - accent-color: var(--thunder-color-primary-main); + accent-color: var(--thunderid-color-primary-main); flex-shrink: 0; - border-radius: var(--thunder-border-radius-xs); + border-radius: var(--thunderid-border-radius-xs); } .thunderid-checkbox__input:focus-visible { outline: none; - box-shadow: 0 0 0 var(--thunder-focus-ring-width) var(--thunder-focus-ring-color); + box-shadow: 0 0 0 var(--thunderid-focus-ring-width) var(--thunderid-focus-ring-color); } .thunderid-checkbox__input:disabled { cursor: not-allowed; @@ -50,15 +50,15 @@ const CHECKBOX_CSS = ` } .thunderid-checkbox__label { - font-size: var(--thunder-typography-fontSize-md); - color: var(--thunder-color-text-primary); - line-height: var(--thunder-typography-lineHeight-normal); + font-size: var(--thunderid-typography-fontSize-md); + color: var(--thunderid-color-text-primary); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-checkbox__error { - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-error-contrastText); - line-height: var(--thunder-typography-lineHeight-normal); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-error-contrastText); + line-height: var(--thunderid-typography-lineHeight-normal); } `; diff --git a/packages/vue/src/components/primitives/Checkbox/Checkbox.ts b/packages/vue/src/components/primitives/Checkbox/Checkbox.ts index 28a8468d..d945cc21 100644 --- a/packages/vue/src/components/primitives/Checkbox/Checkbox.ts +++ b/packages/vue/src/components/primitives/Checkbox/Checkbox.ts @@ -14,7 +14,7 @@ type CheckboxProps = Readonly<{ }>; const Checkbox: Component = defineComponent({ - name: 'ThunderIDCheckbox', + name: 'Checkbox', props: { disabled: {default: false, type: Boolean}, error: {default: undefined, type: String}, diff --git a/packages/vue/src/components/primitives/DatePicker/DatePicker.css.ts b/packages/vue/src/components/primitives/DatePicker/DatePicker.css.ts index 30630ae6..5919c7c6 100644 --- a/packages/vue/src/components/primitives/DatePicker/DatePicker.css.ts +++ b/packages/vue/src/components/primitives/DatePicker/DatePicker.css.ts @@ -20,62 +20,62 @@ const DATE_PICKER_CSS = ` .thunderid-date-picker { display: flex; flex-direction: column; - gap: calc(var(--thunder-spacing-unit) * 0.5); - font-family: var(--thunder-typography-fontFamily); + gap: calc(var(--thunderid-spacing-unit) * 0.5); + font-family: var(--thunderid-typography-fontFamily); width: 100%; box-sizing: border-box; } .thunderid-date-picker__label { - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-medium); - color: var(--thunder-color-text-primary); + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-medium); + color: var(--thunderid-color-text-primary); display: block; - line-height: var(--thunder-typography-lineHeight-normal); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-date-picker__required { - color: var(--thunder-color-error-main); + color: var(--thunderid-color-error-main); margin-left: 2px; } .thunderid-date-picker__input { width: 100%; - height: var(--thunder-input-height); - padding: 0 var(--thunder-input-paddingX); - border: 1px solid var(--thunder-input-borderColor); - border-radius: var(--thunder-input-borderRadius); - font-family: var(--thunder-typography-fontFamily); - font-size: var(--thunder-input-fontSize); - color: var(--thunder-color-text-primary); - background-color: var(--thunder-color-background-surface); + height: var(--thunderid-input-height); + padding: 0 var(--thunderid-input-paddingX); + border: 1px solid var(--thunderid-input-borderColor); + border-radius: var(--thunderid-input-borderRadius); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-input-fontSize); + color: var(--thunderid-color-text-primary); + background-color: var(--thunderid-color-background-surface); box-sizing: border-box; transition: - border-color var(--thunder-transition-fast), - box-shadow var(--thunder-transition-fast); + border-color var(--thunderid-transition-fast), + box-shadow var(--thunderid-transition-fast); outline: none; cursor: pointer; } .thunderid-date-picker__input:focus { - border-color: var(--thunder-input-focusBorderColor); - box-shadow: var(--thunder-input-focusRing); + border-color: var(--thunderid-input-focusBorderColor); + box-shadow: var(--thunderid-input-focusRing); } .thunderid-date-picker--error .thunderid-date-picker__input { - border-color: var(--thunder-color-error-main); + border-color: var(--thunderid-color-error-main); } .thunderid-date-picker--error .thunderid-date-picker__input:focus { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); } .thunderid-date-picker__input:disabled { - background-color: var(--thunder-color-background-disabled); - color: var(--thunder-color-action-disabled); + background-color: var(--thunderid-color-background-disabled); + color: var(--thunderid-color-action-disabled); cursor: not-allowed; } .thunderid-date-picker__error { - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-error-contrastText); - line-height: var(--thunder-typography-lineHeight-normal); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-error-contrastText); + line-height: var(--thunderid-typography-lineHeight-normal); } `; diff --git a/packages/vue/src/components/primitives/DatePicker/DatePicker.ts b/packages/vue/src/components/primitives/DatePicker/DatePicker.ts index 95af463f..b4273df1 100644 --- a/packages/vue/src/components/primitives/DatePicker/DatePicker.ts +++ b/packages/vue/src/components/primitives/DatePicker/DatePicker.ts @@ -15,7 +15,7 @@ type DatePickerProps = Readonly<{ }>; const DatePicker: Component = defineComponent({ - name: 'ThunderIDDatePicker', + name: 'DatePicker', props: { disabled: {default: false, type: Boolean}, error: {default: undefined, type: String}, diff --git a/packages/vue/src/components/primitives/Divider/Divider.css.ts b/packages/vue/src/components/primitives/Divider/Divider.css.ts index 2f9bceaa..1a9ec84d 100644 --- a/packages/vue/src/components/primitives/Divider/Divider.css.ts +++ b/packages/vue/src/components/primitives/Divider/Divider.css.ts @@ -26,8 +26,8 @@ const DIVIDER_CSS = ` .thunderid-divider--horizontal { width: 100%; border: none; - border-top: 1px solid var(--thunder-color-border); - margin: calc(var(--thunder-spacing-unit) * 1) 0; + border-top: 1px solid var(--thunderid-color-border); + margin: calc(var(--thunderid-spacing-unit) * 1) 0; } .thunderid-divider--vertical { @@ -36,34 +36,34 @@ const DIVIDER_CSS = ` height: 100%; min-height: 1em; border: none; - background-color: var(--thunder-color-border); - margin: 0 calc(var(--thunder-spacing-unit) * 1); + background-color: var(--thunderid-color-border); + margin: 0 calc(var(--thunderid-spacing-unit) * 1); align-self: stretch; } .thunderid-divider--with-content { display: flex; align-items: center; - gap: calc(var(--thunder-spacing-unit) * 1); + gap: calc(var(--thunderid-spacing-unit) * 1); border: none; - margin: calc(var(--thunder-spacing-unit) * 1) 0; + margin: calc(var(--thunderid-spacing-unit) * 1) 0; } .thunderid-divider__line { flex: 1; height: 1px; - background-color: var(--thunder-color-border); + background-color: var(--thunderid-color-border); } .thunderid-divider__content { flex-shrink: 0; - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-text-secondary); - padding: 0 calc(var(--thunder-spacing-unit) * 0.5); - font-family: var(--thunder-typography-fontFamily); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-text-secondary); + padding: 0 calc(var(--thunderid-spacing-unit) * 0.5); + font-family: var(--thunderid-typography-fontFamily); text-transform: uppercase; - letter-spacing: var(--thunder-typography-letterSpacing-wide); - font-weight: var(--thunder-typography-fontWeight-medium); + letter-spacing: var(--thunderid-typography-letterSpacing-wide); + font-weight: var(--thunderid-typography-fontWeight-medium); } `; diff --git a/packages/vue/src/components/primitives/Logo/Logo.css.ts b/packages/vue/src/components/primitives/Logo/Logo.css.ts index 44a1b584..a2eaff43 100644 --- a/packages/vue/src/components/primitives/Logo/Logo.css.ts +++ b/packages/vue/src/components/primitives/Logo/Logo.css.ts @@ -18,7 +18,7 @@ const LOGO_CSS = ` display: inline-flex; align-items: center; text-decoration: none; - transition: opacity var(--thunder-transition-fast); + transition: opacity var(--thunderid-transition-fast); } .thunderid-logo:hover { diff --git a/packages/vue/src/components/primitives/OtpField/OtpField.css.ts b/packages/vue/src/components/primitives/OtpField/OtpField.css.ts index cb5317ae..4d8f7007 100644 --- a/packages/vue/src/components/primitives/OtpField/OtpField.css.ts +++ b/packages/vue/src/components/primitives/OtpField/OtpField.css.ts @@ -17,59 +17,67 @@ const OTP_FIELD_CSS = ` .thunderid-otp-field { display: flex; flex-direction: column; - gap: calc(var(--thunder-spacing-unit) * 0.75); - font-family: var(--thunder-typography-fontFamily); + gap: calc(var(--thunderid-spacing-unit) * 0.75); + font-family: var(--thunderid-typography-fontFamily); } .thunderid-otp-field__label { - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-medium); - color: var(--thunder-color-text-primary); + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-medium); + color: var(--thunderid-color-text-primary); display: block; - line-height: var(--thunder-typography-lineHeight-normal); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-otp-field__required { - color: var(--thunder-color-error-main); + color: var(--thunderid-color-error-main); margin-left: 2px; } .thunderid-otp-field__inputs { display: flex; - gap: calc(var(--thunder-spacing-unit) * 0.75); + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--thunderid-spacing-unit); } .thunderid-otp-field__digit { - width: var(--thunder-input-height); - height: var(--thunder-input-height); + flex: none; + width: calc(var(--thunderid-spacing-unit) * 6); + height: calc(var(--thunderid-spacing-unit) * 6); text-align: center; - border: 1px solid var(--thunder-input-borderColor); - border-radius: var(--thunder-input-borderRadius); - font-family: var(--thunder-typography-fontFamily); - font-size: var(--thunder-typography-fontSize-lg); - font-weight: var(--thunder-typography-fontWeight-semibold); - color: var(--thunder-color-text-primary); - background-color: var(--thunder-color-background-surface); + border: 2px solid var(--thunderid-input-borderColor); + /* Keeps the digit box's corners between a subtle minimum and 25% of its own + size, so a brand radius token of 0 (sharp cards/buttons) doesn't render + as a harsh square here, and an oversized token can't turn it into a + near-circle. */ + border-radius: clamp(4px, var(--thunderid-border-radius-medium), 25%); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-typography-fontSize-xl); + font-weight: var(--thunderid-typography-fontWeight-medium); + color: var(--thunderid-color-text-primary); + background-color: var(--thunderid-color-background-surface); box-sizing: border-box; outline: none; transition: - border-color var(--thunder-transition-fast), - box-shadow var(--thunder-transition-fast); + border-color var(--thunderid-transition-fast), + box-shadow var(--thunderid-transition-fast); } .thunderid-otp-field__digit:focus { - border-color: var(--thunder-input-focusBorderColor); - box-shadow: var(--thunder-input-focusRing); + border-color: var(--thunderid-input-focusBorderColor); + box-shadow: var(--thunderid-input-focusRing); } .thunderid-otp-field__digit:disabled { - background-color: var(--thunder-color-background-disabled); - color: var(--thunder-color-action-disabled); + background-color: var(--thunderid-color-background-disabled); + color: var(--thunderid-color-action-disabled); cursor: not-allowed; } .thunderid-otp-field__error { - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-error-contrastText); - line-height: var(--thunder-typography-lineHeight-normal); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-error-contrastText); + line-height: var(--thunderid-typography-lineHeight-normal); } `; diff --git a/packages/vue/src/components/primitives/OtpField/OtpField.ts b/packages/vue/src/components/primitives/OtpField/OtpField.ts index 0fc11717..8622a476 100644 --- a/packages/vue/src/components/primitives/OtpField/OtpField.ts +++ b/packages/vue/src/components/primitives/OtpField/OtpField.ts @@ -64,6 +64,31 @@ const OtpField: Component = defineComponent({ } }; + const handlePaste = (index: number, e: ClipboardEvent): void => { + e.preventDefault(); + const rawData: string = e.clipboardData?.getData('text') || ''; + const pasted: string = props.numericOnly + ? rawData.replace(NON_NUMERIC_OTP_CHARS, '') + : rawData.toUpperCase().replace(NON_ALPHANUMERIC_OTP_CHARS, ''); + + if (!pasted) return; + + const current: string[] = (props.modelValue || '').split(''); + while (current.length < props.length) current.push(''); + + let cursor: number = index; + for (const char of pasted) { + if (cursor >= props.length) break; + current[cursor] = char; + cursor += 1; + } + + emit('update:modelValue', current.join('')); + + const nextIndex: number = Math.min(cursor, props.length - 1); + nextTick(() => inputRefs.value[nextIndex]?.focus()); + }; + return (): VNode => { const digits: string[] = (props.modelValue || '').split(''); while (digits.length < props.length) digits.push(''); @@ -94,6 +119,7 @@ const OtpField: Component = defineComponent({ maxlength: 1, onInput: (e: Event) => handleInput(i, e), onKeydown: (e: KeyboardEvent) => handleKeydown(i, e), + onPaste: (e: ClipboardEvent) => handlePaste(i, e), ref: (el: unknown) => setRef(el, i), type: 'text', value: digits[i], diff --git a/packages/vue/src/components/primitives/PasswordField/PasswordField.css.ts b/packages/vue/src/components/primitives/PasswordField/PasswordField.css.ts index 3b5a54e1..0d095eb1 100644 --- a/packages/vue/src/components/primitives/PasswordField/PasswordField.css.ts +++ b/packages/vue/src/components/primitives/PasswordField/PasswordField.css.ts @@ -20,44 +20,44 @@ const PASSWORD_FIELD_CSS = ` .thunderid-password-field { display: flex; flex-direction: column; - gap: calc(var(--thunder-spacing-unit) * 0.5); - font-family: var(--thunder-typography-fontFamily); + gap: calc(var(--thunderid-spacing-unit) * 0.5); + font-family: var(--thunderid-typography-fontFamily); width: 100%; box-sizing: border-box; } .thunderid-password-field__label { - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-medium); - color: var(--thunder-color-text-primary); + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-medium); + color: var(--thunderid-color-text-primary); display: block; - line-height: var(--thunder-typography-lineHeight-normal); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-password-field__required { - color: var(--thunder-color-error-main); + color: var(--thunderid-color-error-main); margin-left: 2px; } .thunderid-password-field__wrapper { display: flex; align-items: center; - height: var(--thunder-input-height); - border: 1px solid var(--thunder-input-borderColor); - border-radius: var(--thunder-input-borderRadius); - background-color: var(--thunder-color-background-surface); + height: var(--thunderid-input-height); + border: 1px solid var(--thunderid-input-borderColor); + border-radius: var(--thunderid-input-borderRadius); + background-color: var(--thunderid-color-background-surface); transition: - border-color var(--thunder-transition-fast), - box-shadow var(--thunder-transition-fast); + border-color var(--thunderid-transition-fast), + box-shadow var(--thunderid-transition-fast); overflow: hidden; box-sizing: border-box; } .thunderid-password-field__wrapper:focus-within { - border-color: var(--thunder-input-focusBorderColor); - box-shadow: var(--thunder-input-focusRing); + border-color: var(--thunderid-input-focusBorderColor); + box-shadow: var(--thunderid-input-focusRing); } .thunderid-password-field--error .thunderid-password-field__wrapper { - border-color: var(--thunder-color-error-main); + border-color: var(--thunderid-color-error-main); } .thunderid-password-field--error .thunderid-password-field__wrapper:focus-within { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); @@ -65,12 +65,12 @@ const PASSWORD_FIELD_CSS = ` .thunderid-password-field__input { flex: 1; - padding: 0 var(--thunder-input-paddingX); + padding: 0 var(--thunderid-input-paddingX); border: none; outline: none; - font-family: var(--thunder-typography-fontFamily); - font-size: var(--thunder-input-fontSize); - color: var(--thunder-color-text-primary); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-input-fontSize); + color: var(--thunderid-color-text-primary); background: transparent; width: 100%; height: 100%; @@ -78,7 +78,7 @@ const PASSWORD_FIELD_CSS = ` min-width: 0; } .thunderid-password-field__input::placeholder { - color: var(--thunder-color-text-secondary); + color: var(--thunderid-color-text-secondary); } .thunderid-password-field__input:disabled { cursor: not-allowed; @@ -88,24 +88,24 @@ const PASSWORD_FIELD_CSS = ` background: none; border: none; cursor: pointer; - padding: 0 var(--thunder-input-paddingX); - color: var(--thunder-color-text-secondary); - font-size: var(--thunder-typography-fontSize-md); + padding: 0 var(--thunderid-input-paddingX); + color: var(--thunderid-color-text-secondary); + font-size: var(--thunderid-typography-fontSize-md); display: flex; align-items: center; justify-content: center; flex-shrink: 0; height: 100%; - transition: color var(--thunder-transition-fast); + transition: color var(--thunderid-transition-fast); } .thunderid-password-field__toggle:hover { - color: var(--thunder-color-text-primary); + color: var(--thunderid-color-text-primary); } .thunderid-password-field__error { - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-error-contrastText); - line-height: var(--thunder-typography-lineHeight-normal); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-error-contrastText); + line-height: var(--thunderid-typography-lineHeight-normal); } `; diff --git a/packages/vue/src/components/primitives/Select/Select.css.ts b/packages/vue/src/components/primitives/Select/Select.css.ts index 28bccbc0..c1abc372 100644 --- a/packages/vue/src/components/primitives/Select/Select.css.ts +++ b/packages/vue/src/components/primitives/Select/Select.css.ts @@ -20,75 +20,75 @@ const SELECT_CSS = ` .thunderid-select { display: flex; flex-direction: column; - gap: calc(var(--thunder-spacing-unit) * 0.5); - font-family: var(--thunder-typography-fontFamily); + gap: calc(var(--thunderid-spacing-unit) * 0.5); + font-family: var(--thunderid-typography-fontFamily); width: 100%; box-sizing: border-box; } .thunderid-select__label { - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-medium); - color: var(--thunder-color-text-primary); + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-medium); + color: var(--thunderid-color-text-primary); display: block; - line-height: var(--thunder-typography-lineHeight-normal); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-select__required { - color: var(--thunder-color-error-main); + color: var(--thunderid-color-error-main); margin-left: 2px; } .thunderid-select__input { width: 100%; - height: var(--thunder-input-height); - padding: 0 calc(var(--thunder-spacing-unit) * 4) 0 var(--thunder-input-paddingX); - border: 1px solid var(--thunder-input-borderColor); - border-radius: var(--thunder-input-borderRadius); - font-family: var(--thunder-typography-fontFamily); - font-size: var(--thunder-input-fontSize); - color: var(--thunder-color-text-primary); - background-color: var(--thunder-color-background-surface); + height: var(--thunderid-input-height); + padding: 0 calc(var(--thunderid-spacing-unit) * 4) 0 var(--thunderid-input-paddingX); + border: 1px solid var(--thunderid-input-borderColor); + border-radius: var(--thunderid-input-borderRadius); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-input-fontSize); + color: var(--thunderid-color-text-primary); + background-color: var(--thunderid-color-background-surface); appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; - background-position: right var(--thunder-input-paddingX) center; + background-position: right var(--thunderid-input-paddingX) center; cursor: pointer; box-sizing: border-box; transition: - border-color var(--thunder-transition-fast), - box-shadow var(--thunder-transition-fast); + border-color var(--thunderid-transition-fast), + box-shadow var(--thunderid-transition-fast); outline: none; - line-height: var(--thunder-typography-lineHeight-normal); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-select__input:focus { - border-color: var(--thunder-input-focusBorderColor); - box-shadow: var(--thunder-input-focusRing); + border-color: var(--thunderid-input-focusBorderColor); + box-shadow: var(--thunderid-input-focusRing); } .thunderid-select__input:disabled { - background-color: var(--thunder-color-background-disabled); - color: var(--thunder-color-action-disabled); + background-color: var(--thunderid-color-background-disabled); + color: var(--thunderid-color-action-disabled); cursor: not-allowed; } .thunderid-select--error .thunderid-select__input { - border-color: var(--thunder-color-error-main); + border-color: var(--thunderid-color-error-main); } .thunderid-select--error .thunderid-select__input:focus { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); } .thunderid-select__error { - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-error-contrastText); - line-height: var(--thunder-typography-lineHeight-normal); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-error-contrastText); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-select__helper { - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-text-secondary); - line-height: var(--thunder-typography-lineHeight-normal); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-text-secondary); + line-height: var(--thunderid-typography-lineHeight-normal); } `; diff --git a/packages/vue/src/components/primitives/Select/Select.ts b/packages/vue/src/components/primitives/Select/Select.ts index 58024db8..b459065c 100644 --- a/packages/vue/src/components/primitives/Select/Select.ts +++ b/packages/vue/src/components/primitives/Select/Select.ts @@ -23,7 +23,7 @@ type SelectProps = Readonly<{ }>; const Select: Component = defineComponent({ - name: 'ThunderIDSelect', + name: 'Select', props: { disabled: {default: false, type: Boolean}, error: {default: undefined, type: String}, diff --git a/packages/vue/src/components/primitives/Spinner/Spinner.css.ts b/packages/vue/src/components/primitives/Spinner/Spinner.css.ts index 4e16bdbf..bd40c199 100644 --- a/packages/vue/src/components/primitives/Spinner/Spinner.css.ts +++ b/packages/vue/src/components/primitives/Spinner/Spinner.css.ts @@ -24,22 +24,22 @@ const SPINNER_CSS = ` display: inline-flex; align-items: center; justify-content: center; - color: var(--thunder-color-primary-main); + color: var(--thunderid-color-primary-main); } .thunderid-spinner--small { - width: calc(var(--thunder-spacing-unit) * 2); - height: calc(var(--thunder-spacing-unit) * 2); + width: calc(var(--thunderid-spacing-unit) * 2); + height: calc(var(--thunderid-spacing-unit) * 2); } .thunderid-spinner--medium { - width: calc(var(--thunder-spacing-unit) * 2.5); - height: calc(var(--thunder-spacing-unit) * 2.5); + width: calc(var(--thunderid-spacing-unit) * 2.5); + height: calc(var(--thunderid-spacing-unit) * 2.5); } .thunderid-spinner--large { - width: calc(var(--thunder-spacing-unit) * 3.5); - height: calc(var(--thunder-spacing-unit) * 3.5); + width: calc(var(--thunderid-spacing-unit) * 3.5); + height: calc(var(--thunderid-spacing-unit) * 3.5); } .thunderid-spinner__svg { diff --git a/packages/vue/src/components/primitives/TextField/TextField.css.ts b/packages/vue/src/components/primitives/TextField/TextField.css.ts index 36f91298..67af25b5 100644 --- a/packages/vue/src/components/primitives/TextField/TextField.css.ts +++ b/packages/vue/src/components/primitives/TextField/TextField.css.ts @@ -20,71 +20,71 @@ const TEXT_FIELD_CSS = ` .thunderid-text-field { display: flex; flex-direction: column; - gap: calc(var(--thunder-spacing-unit) * 0.5); - font-family: var(--thunder-typography-fontFamily); + gap: calc(var(--thunderid-spacing-unit) * 0.5); + font-family: var(--thunderid-typography-fontFamily); width: 100%; box-sizing: border-box; } .thunderid-text-field__label { - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-medium); - color: var(--thunder-color-text-primary); + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-medium); + color: var(--thunderid-color-text-primary); display: block; - line-height: var(--thunder-typography-lineHeight-normal); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-text-field__required { - color: var(--thunder-color-error-main); + color: var(--thunderid-color-error-main); margin-left: 2px; } .thunderid-text-field__input { width: 100%; - height: var(--thunder-input-height); - padding: 0 var(--thunder-input-paddingX); - border: 1px solid var(--thunder-input-borderColor); - border-radius: var(--thunder-input-borderRadius); - font-family: var(--thunder-typography-fontFamily); - font-size: var(--thunder-input-fontSize); - color: var(--thunder-color-text-primary); - background-color: var(--thunder-color-background-surface); + height: var(--thunderid-input-height); + padding: 0 var(--thunderid-input-paddingX); + border: 1px solid var(--thunderid-input-borderColor); + border-radius: var(--thunderid-input-borderRadius); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-input-fontSize); + color: var(--thunderid-color-text-primary); + background-color: var(--thunderid-color-background-surface); box-sizing: border-box; transition: - border-color var(--thunder-transition-fast), - box-shadow var(--thunder-transition-fast); + border-color var(--thunderid-transition-fast), + box-shadow var(--thunderid-transition-fast); outline: none; } .thunderid-text-field__input:focus { - border-color: var(--thunder-input-focusBorderColor); - box-shadow: var(--thunder-input-focusRing); + border-color: var(--thunderid-input-focusBorderColor); + box-shadow: var(--thunderid-input-focusRing); } .thunderid-text-field__input::placeholder { - color: var(--thunder-color-text-secondary); + color: var(--thunderid-color-text-secondary); } .thunderid-text-field__input:disabled { - background-color: var(--thunder-color-background-disabled); - color: var(--thunder-color-action-disabled); + background-color: var(--thunderid-color-background-disabled); + color: var(--thunderid-color-action-disabled); cursor: not-allowed; } .thunderid-text-field--error .thunderid-text-field__input { - border-color: var(--thunder-color-error-main); + border-color: var(--thunderid-color-error-main); } .thunderid-text-field--error .thunderid-text-field__input:focus { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); } .thunderid-text-field__error { - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-error-contrastText); - line-height: var(--thunder-typography-lineHeight-normal); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-error-contrastText); + line-height: var(--thunderid-typography-lineHeight-normal); } .thunderid-text-field__helper { - font-size: var(--thunder-typography-fontSize-xs); - color: var(--thunder-color-text-secondary); - line-height: var(--thunder-typography-lineHeight-normal); + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-text-secondary); + line-height: var(--thunderid-typography-lineHeight-normal); } `; diff --git a/packages/vue/src/components/primitives/Typography/Typography.css.ts b/packages/vue/src/components/primitives/Typography/Typography.css.ts index 984bf5b6..dbe0b4db 100644 --- a/packages/vue/src/components/primitives/Typography/Typography.css.ts +++ b/packages/vue/src/components/primitives/Typography/Typography.css.ts @@ -18,87 +18,87 @@ const TYPOGRAPHY_CSS = ` ============================================================ */ .thunderid-typography { - font-family: var(--thunder-typography-fontFamily); - color: var(--thunder-color-text-primary); + font-family: var(--thunderid-typography-fontFamily); + color: var(--thunderid-color-text-primary); margin: 0; - line-height: var(--thunder-typography-lineHeight-normal); + line-height: var(--thunderid-typography-lineHeight-normal); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .thunderid-typography--h1 { - font-size: var(--thunder-typography-fontSize-3xl); - font-weight: var(--thunder-typography-fontWeight-bold); - line-height: var(--thunder-typography-lineHeight-tight); - letter-spacing: var(--thunder-typography-letterSpacing-tight); + font-size: var(--thunderid-typography-fontSize-3xl); + font-weight: var(--thunderid-typography-fontWeight-bold); + line-height: var(--thunderid-typography-lineHeight-tight); + letter-spacing: var(--thunderid-typography-letterSpacing-tight); } .thunderid-typography--h2 { - font-size: var(--thunder-typography-fontSize-2xl); - font-weight: var(--thunder-typography-fontWeight-bold); - line-height: var(--thunder-typography-lineHeight-tight); - letter-spacing: var(--thunder-typography-letterSpacing-tight); + font-size: var(--thunderid-typography-fontSize-2xl); + font-weight: var(--thunderid-typography-fontWeight-bold); + line-height: var(--thunderid-typography-lineHeight-tight); + letter-spacing: var(--thunderid-typography-letterSpacing-tight); } .thunderid-typography--h3 { - font-size: var(--thunder-typography-fontSize-xl); - font-weight: var(--thunder-typography-fontWeight-semibold); - line-height: var(--thunder-typography-lineHeight-tight); + font-size: var(--thunderid-typography-fontSize-xl); + font-weight: var(--thunderid-typography-fontWeight-semibold); + line-height: var(--thunderid-typography-lineHeight-tight); } .thunderid-typography--h4 { - font-size: var(--thunder-typography-fontSize-lg); - font-weight: var(--thunder-typography-fontWeight-semibold); + font-size: var(--thunderid-typography-fontSize-lg); + font-weight: var(--thunderid-typography-fontWeight-semibold); } .thunderid-typography--h5 { - font-size: var(--thunder-typography-fontSize-md); - font-weight: var(--thunder-typography-fontWeight-semibold); + font-size: var(--thunderid-typography-fontSize-md); + font-weight: var(--thunderid-typography-fontWeight-semibold); } .thunderid-typography--h6 { - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-semibold); + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-semibold); text-transform: uppercase; - letter-spacing: var(--thunder-typography-letterSpacing-wide); + letter-spacing: var(--thunderid-typography-letterSpacing-wide); } .thunderid-typography--subtitle1 { - font-size: var(--thunder-typography-fontSize-lg); - font-weight: var(--thunder-typography-fontWeight-medium); + font-size: var(--thunderid-typography-fontSize-lg); + font-weight: var(--thunderid-typography-fontWeight-medium); } .thunderid-typography--subtitle2 { - font-size: var(--thunder-typography-fontSize-md); - font-weight: var(--thunder-typography-fontWeight-medium); - color: var(--thunder-color-text-secondary); + font-size: var(--thunderid-typography-fontSize-md); + font-weight: var(--thunderid-typography-fontWeight-medium); + color: var(--thunderid-color-text-secondary); } .thunderid-typography--body1 { - font-size: var(--thunder-typography-fontSize-md); - font-weight: var(--thunder-typography-fontWeight-normal); - line-height: var(--thunder-typography-lineHeight-relaxed); + font-size: var(--thunderid-typography-fontSize-md); + font-weight: var(--thunderid-typography-fontWeight-normal); + line-height: var(--thunderid-typography-lineHeight-relaxed); } .thunderid-typography--body2 { - font-size: var(--thunder-typography-fontSize-sm); - font-weight: var(--thunder-typography-fontWeight-normal); - line-height: var(--thunder-typography-lineHeight-relaxed); - color: var(--thunder-color-text-secondary); + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-normal); + line-height: var(--thunderid-typography-lineHeight-relaxed); + color: var(--thunderid-color-text-secondary); } .thunderid-typography--caption { - font-size: var(--thunder-typography-fontSize-xs); - font-weight: var(--thunder-typography-fontWeight-normal); - color: var(--thunder-color-text-secondary); + font-size: var(--thunderid-typography-fontSize-xs); + font-weight: var(--thunderid-typography-fontWeight-normal); + color: var(--thunderid-color-text-secondary); } .thunderid-typography--overline { - font-size: var(--thunder-typography-fontSize-xs); - font-weight: var(--thunder-typography-fontWeight-medium); + font-size: var(--thunderid-typography-fontSize-xs); + font-weight: var(--thunderid-typography-fontWeight-medium); text-transform: uppercase; letter-spacing: 0.08em; - color: var(--thunder-color-text-secondary); + color: var(--thunderid-color-text-secondary); } `; diff --git a/packages/vue/src/composables/useOAuthCallback.ts b/packages/vue/src/composables/useOAuthCallback.ts index aeeba521..c31883e9 100644 --- a/packages/vue/src/composables/useOAuthCallback.ts +++ b/packages/vue/src/composables/useOAuthCallback.ts @@ -96,6 +96,13 @@ export function useOAuthCallback({ return; } + // SSR-safe: `window`/`sessionStorage` are unavailable server-side (e.g. during Nuxt's + // SSR render pass). The real OAuth-callback processing only ever needs to happen once + // mounted client-side, where the redirect actually lands. + if (typeof window === 'undefined') { + return; + } + const urlParams: URLSearchParams = new URLSearchParams(window.location.search); const code: string | null = urlParams.get('code'); const nonce: string | null = urlParams.get('nonce'); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index f4b23a36..a5181461 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -161,6 +161,9 @@ export {hasAuthParamsInUrl} from './utils/hasAuthParamsInUrl'; export {navigate} from './utils/navigate'; export {http} from './utils/http'; export {initiateOAuthRedirect} from './utils/oauth'; +export {extractErrorMessage, normalizeFlowResponse} from './utils/flowTransformer'; +export type {FlowErrorResponse, FlowTransformOptions} from './utils/flowTransformer'; +export {handlePasskeyAuthentication, handlePasskeyRegistration} from './utils/passkey'; export {default as getUsersMeMeta} from './api/getUsersMeMeta'; export * from './api/getUsersMeMeta'; diff --git a/packages/vue/src/providers/FlowMetaProvider.ts b/packages/vue/src/providers/FlowMetaProvider.ts index e40a8e11..a7272aff 100644 --- a/packages/vue/src/providers/FlowMetaProvider.ts +++ b/packages/vue/src/providers/FlowMetaProvider.ts @@ -20,6 +20,7 @@ import { ref, watch, type Component, + type PropType, type Ref, type SetupContext, type VNode, @@ -38,6 +39,8 @@ import type {ThunderIDContext, FlowMetaContextValue, I18nContextValue} from '../ */ interface FlowMetaProviderProps { enabled: boolean; + fetchMeta?: (params: {applicationId?: string; language?: string}) => Promise; + initialMeta?: FlowMetadataResponse | null; } const FlowMetaProvider: Component = defineComponent({ @@ -48,12 +51,29 @@ const FlowMetaProvider: Component = defineComponent({ * @default true */ enabled: {default: true, type: Boolean}, + /** + * Overrides how flow metadata is fetched, routing the request through a caller-supplied + * function (e.g. a Nuxt server route called via `$fetch`) instead of this provider's default + * direct browser-to-`baseUrl` `fetch()`. Use this when the ThunderID server's `baseUrl` is a + * different origin than the app and CORS isn't (or can't be) configured for it — the override + * runs server-side, so the browser never talks to `baseUrl` directly. + * + * Called for both the initial fetch and `switchLanguage()`. + */ + fetchMeta: {default: undefined, type: Function as PropType}, + /** + * Flow metadata resolved ahead of time (e.g. fetched server-side during SSR) and used to seed + * this provider's state. When present, the provider skips its own initial client-side fetch — + * avoiding a redundant request and the flash of untranslated i18n keys while that fetch is in + * flight — but still fetches normally on subsequent changes (e.g. an explicit language switch). + */ + initialMeta: {default: null, type: Object as PropType}, }, setup(props: FlowMetaProviderProps, {slots}: SetupContext): () => VNode { const thunderIDContext: ThunderIDContext | undefined = inject(THUNDERID_KEY); const i18nContext: I18nContextValue | null = inject(I18N_KEY, null); - const meta: Ref = ref(null); + const meta: Ref = ref(props.initialMeta ?? null); const isLoading: Ref = ref(false); const error: Ref = ref(null); const pendingLanguage: Ref = ref(null); @@ -74,11 +94,13 @@ const FlowMetaProvider: Component = defineComponent({ error.value = null; try { - const result: FlowMetadataResponse = await getFlowMeta({ - baseUrl, - url: flowMetaUrl, - ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), - }); + const result: FlowMetadataResponse = props.fetchMeta + ? await props.fetchMeta({applicationId}) + : await getFlowMeta({ + baseUrl, + url: flowMetaUrl, + ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), + }); meta.value = result; } catch (err: unknown) { error.value = err instanceof Error ? err : new Error(String(err)); @@ -94,12 +116,14 @@ const FlowMetaProvider: Component = defineComponent({ error.value = null; try { - const result: FlowMetadataResponse = await getFlowMeta({ - baseUrl, - url: flowMetaUrl, - ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), - language, - }); + const result: FlowMetadataResponse = props.fetchMeta + ? await props.fetchMeta({applicationId, language}) + : await getFlowMeta({ + baseUrl, + url: flowMetaUrl, + ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), + language, + }); // Inject translations before switching language so the i18n state is updated if (result.i18n?.translations && i18nContext?.injectBundles) { @@ -164,6 +188,8 @@ const FlowMetaProvider: Component = defineComponent({ ); onMounted(() => { + // Seeded from SSR (or another caller) — skip the redundant first client-side fetch. + if (props.initialMeta) return; fetchFlowMeta(); }); diff --git a/packages/vue/src/providers/ThemeProvider.ts b/packages/vue/src/providers/ThemeProvider.ts index 07a9d381..4b4caef9 100644 --- a/packages/vue/src/providers/ThemeProvider.ts +++ b/packages/vue/src/providers/ThemeProvider.ts @@ -9,6 +9,7 @@ import { RecursivePartial, BrowserThemeDetection, DEFAULT_THEME, + FlowMetaTheme, createTheme, detectThemeMode, createClassObserver, @@ -18,6 +19,7 @@ import { computed, defineComponent, h, + inject, onBeforeUnmount, onMounted, provide, @@ -31,8 +33,9 @@ import { type SetupContext, type VNode, } from 'vue'; -import {THEME_KEY} from '../keys'; -import type {ThemeContextValue} from '../models/contexts'; +import {FLOW_META_KEY, THEME_KEY} from '../keys'; +import type {FlowMetaContextValue, ThemeContextValue} from '../models/contexts'; +import buildThemeConfigFromFlowMeta from '../utils/buildThemeConfigFromFlowMeta'; /** * ThemeProvider manages theme state and provides it to child components via `useTheme()`. @@ -76,17 +79,68 @@ const ThemeProvider: Component = defineComponent({ theme: {default: undefined, type: Object as PropType>}, }, setup(props: ThemeProviderProps, {slots}: SetupContext): () => VNode { + const flowMetaContext: FlowMetaContextValue | null = inject(FLOW_META_KEY, null); + const flowMetaTheme: Ref = computed( + () => flowMetaContext?.meta.value?.design?.theme ?? null, + ); + const initColorScheme = (): 'light' | 'dark' => { if (props.mode === 'light' || props.mode === 'dark') return props.mode; - if (props.mode === 'branding') return detectThemeMode('system', props.detection); + if (props.mode === 'branding') + return flowMetaTheme.value?.defaultColorScheme ?? detectThemeMode('system', props.detection); return detectThemeMode(props.mode as ThemeMode, props.detection); }; const colorScheme: Ref<'light' | 'dark'> = ref(initColorScheme()); + // In 'branding' mode, sync the color scheme once the server's default arrives. + watch( + () => flowMetaTheme.value?.defaultColorScheme, + (defaultColorScheme: 'light' | 'dark' | undefined): void => { + if (props.mode === 'branding' && defaultColorScheme) { + colorScheme.value = defaultColorScheme; + } + }, + ); + + // Build the resolved ThemeConfig: flow meta base → user overrides on top. const finalThemeConfig: Ref | undefined> = computed< RecursivePartial | undefined - >(() => props.theme); + >(() => { + if (!flowMetaTheme.value) { + return props.theme; + } + + const metaConfig: RecursivePartial = buildThemeConfigFromFlowMeta( + flowMetaTheme.value, + colorScheme.value, + ); + + if (!props.theme) { + return metaConfig; + } + + return { + ...metaConfig, + ...props.theme, + borderRadius: { + ...(metaConfig as any).borderRadius, + ...(props.theme as any).borderRadius, + }, + colors: { + ...(metaConfig as any).colors, + ...(props.theme as any).colors, + }, + ...((metaConfig as any).typography || (props.theme as any).typography + ? { + typography: { + ...(metaConfig as any).typography, + ...(props.theme as any).typography, + }, + } + : {}), + }; + }); const resolvedTheme: Ref = computed(() => createTheme(finalThemeConfig.value, colorScheme.value === 'dark'), @@ -105,7 +159,7 @@ const ThemeProvider: Component = defineComponent({ if (typeof document === 'undefined') return; const root: HTMLElement = document.documentElement; // Use the pre-computed cssVariables map from createTheme() which contains - // correctly-named CSS variables (e.g. --thunder-color-primary-main). + // correctly-named CSS variables (e.g. --thunderid-color-primary-main). Object.entries(theme.cssVariables).forEach(([key, value]: [key: string, value: string]): void => { root.style.setProperty(key, value); }); diff --git a/packages/vue/src/styles/defaults.css.ts b/packages/vue/src/styles/defaults.css.ts index 1d0fbaf4..a15b525e 100644 --- a/packages/vue/src/styles/defaults.css.ts +++ b/packages/vue/src/styles/defaults.css.ts @@ -11,7 +11,7 @@ * automatically — no special cascade tricks required. * * Design token naming follows the pattern: - * --thunder-{category}-{sub}-{scale?} + * --thunderid-{category}-{sub}-{scale?} */ const DEFAULTS_CSS = ` /* ============================================================ @@ -20,154 +20,154 @@ const DEFAULTS_CSS = ` ============================================================ */ :root { /* --- Colors: Primary --- */ - --thunder-color-primary-main: #4b6ef5; - --thunder-color-primary-light: #eef1fe; - --thunder-color-primary-dark: #3451d1; - --thunder-color-primary-contrastText: #ffffff; + --thunderid-color-primary-main: #4b6ef5; + --thunderid-color-primary-light: #eef1fe; + --thunderid-color-primary-dark: #3451d1; + --thunderid-color-primary-contrastText: #ffffff; /* --- Colors: Secondary --- */ - --thunder-color-secondary-main: #4b5563; - --thunder-color-secondary-light: #f3f4f6; - --thunder-color-secondary-contrastText: #ffffff; + --thunderid-color-secondary-main: #4b5563; + --thunderid-color-secondary-light: #f3f4f6; + --thunderid-color-secondary-contrastText: #ffffff; /* --- Colors: Background --- */ - --thunder-color-background-surface: #ffffff; - --thunder-color-background-body: #f9fafb; - --thunder-color-background-disabled: #f3f4f6; - --thunder-color-background-muted: #f1f3f5; + --thunderid-color-background-surface: #ffffff; + --thunderid-color-background-body: #f9fafb; + --thunderid-color-background-disabled: #f3f4f6; + --thunderid-color-background-muted: #f1f3f5; /* --- Colors: Text --- */ - --thunder-color-text-primary: #111827; - --thunder-color-text-secondary: #6b7280; + --thunderid-color-text-primary: #111827; + --thunderid-color-text-secondary: #6b7280; /* --- Colors: Border --- */ - --thunder-color-border: #e5e7eb; - --thunder-color-border-focus: var(--thunder-color-primary-main); + --thunderid-color-border: #e5e7eb; + --thunderid-color-border-focus: var(--thunderid-color-primary-main); /* --- Colors: Action states --- */ - --thunder-color-action-hover: rgba(0, 0, 0, 0.04); - --thunder-color-action-selected: rgba(75, 110, 245, 0.08); - --thunder-color-action-focus: rgba(75, 110, 245, 0.12); - --thunder-color-action-disabled: rgba(0, 0, 0, 0.26); - --thunder-color-action-disabledBackground: rgba(0, 0, 0, 0.08); + --thunderid-color-action-hover: rgba(0, 0, 0, 0.04); + --thunderid-color-action-selected: rgba(75, 110, 245, 0.08); + --thunderid-color-action-focus: rgba(75, 110, 245, 0.12); + --thunderid-color-action-disabled: rgba(0, 0, 0, 0.26); + --thunderid-color-action-disabledBackground: rgba(0, 0, 0, 0.08); /* --- Colors: Semantic --- */ - --thunder-color-error-main: #ef4444; - --thunder-color-error-light: #fef2f2; - --thunder-color-error-contrastText: #991b1b; - --thunder-color-success-main: #22c55e; - --thunder-color-success-light: #f0fdf4; - --thunder-color-success-contrastText: #166534; - --thunder-color-warning-main: #f59e0b; - --thunder-color-warning-light: #fffbeb; - --thunder-color-warning-contrastText: #92400e; - --thunder-color-info-main: #3b82f6; - --thunder-color-info-light: #eff6ff; - --thunder-color-info-contrastText: #1e40af; + --thunderid-color-error-main: #ef4444; + --thunderid-color-error-light: #fef2f2; + --thunderid-color-error-contrastText: #991b1b; + --thunderid-color-success-main: #22c55e; + --thunderid-color-success-light: #f0fdf4; + --thunderid-color-success-contrastText: #166534; + --thunderid-color-warning-main: #f59e0b; + --thunderid-color-warning-light: #fffbeb; + --thunderid-color-warning-contrastText: #92400e; + --thunderid-color-info-main: #3b82f6; + --thunderid-color-info-light: #eff6ff; + --thunderid-color-info-contrastText: #1e40af; /* --- Spacing --- */ - --thunder-spacing-unit: 8px; + --thunderid-spacing-unit: 8px; /* --- Border Radius --- */ - --thunder-border-radius-xs: 4px; - --thunder-border-radius-small: 6px; - --thunder-border-radius-medium: 10px; - --thunder-border-radius-large: 14px; - --thunder-border-radius-full: 9999px; + --thunderid-border-radius-xs: 4px; + --thunderid-border-radius-small: 6px; + --thunderid-border-radius-medium: 10px; + --thunderid-border-radius-large: 14px; + --thunderid-border-radius-full: 9999px; /* --- Shadows --- */ - --thunder-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); - --thunder-shadow-small: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); - --thunder-shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.05); - --thunder-shadow-large: 0 10px 25px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.05); + --thunderid-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); + --thunderid-shadow-small: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); + --thunderid-shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.05); + --thunderid-shadow-large: 0 10px 25px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.05); /* --- Transitions --- */ - --thunder-transition-fast: 120ms ease; - --thunder-transition-normal: 180ms ease; - --thunder-transition-slow: 280ms ease; + --thunderid-transition-fast: 120ms ease; + --thunderid-transition-normal: 180ms ease; + --thunderid-transition-slow: 280ms ease; /* --- Focus Ring --- */ - --thunder-focus-ring-width: 2px; - --thunder-focus-ring-offset: 2px; - --thunder-focus-ring-color: rgba(75, 110, 245, 0.35); + --thunderid-focus-ring-width: 2px; + --thunderid-focus-ring-offset: 2px; + --thunderid-focus-ring-color: rgba(75, 110, 245, 0.35); /* --- Typography: Font Family --- */ - --thunder-typography-fontFamily: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --thunderid-typography-fontFamily: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; /* --- Typography: Font Sizes --- */ - --thunder-typography-fontSize-xs: 0.6875rem; /* 11px */ - --thunder-typography-fontSize-sm: 0.8125rem; /* 13px */ - --thunder-typography-fontSize-md: 0.875rem; /* 14px */ - --thunder-typography-fontSize-lg: 1rem; /* 16px */ - --thunder-typography-fontSize-xl: 1.125rem; /* 18px */ - --thunder-typography-fontSize-2xl: 1.375rem; /* 22px */ - --thunder-typography-fontSize-3xl: 1.75rem; /* 28px */ + --thunderid-typography-fontSize-xs: 0.6875rem; /* 11px */ + --thunderid-typography-fontSize-sm: 0.8125rem; /* 13px */ + --thunderid-typography-fontSize-md: 0.875rem; /* 14px */ + --thunderid-typography-fontSize-lg: 1rem; /* 16px */ + --thunderid-typography-fontSize-xl: 1.125rem; /* 18px */ + --thunderid-typography-fontSize-2xl: 1.375rem; /* 22px */ + --thunderid-typography-fontSize-3xl: 1.75rem; /* 28px */ /* --- Typography: Font Weights --- */ - --thunder-typography-fontWeight-normal: 400; - --thunder-typography-fontWeight-medium: 500; - --thunder-typography-fontWeight-semibold: 600; - --thunder-typography-fontWeight-bold: 700; + --thunderid-typography-fontWeight-normal: 400; + --thunderid-typography-fontWeight-medium: 500; + --thunderid-typography-fontWeight-semibold: 600; + --thunderid-typography-fontWeight-bold: 700; /* --- Typography: Line Heights --- */ - --thunder-typography-lineHeight-tight: 1.25; - --thunder-typography-lineHeight-normal: 1.5; - --thunder-typography-lineHeight-relaxed: 1.625; + --thunderid-typography-lineHeight-tight: 1.25; + --thunderid-typography-lineHeight-normal: 1.5; + --thunderid-typography-lineHeight-relaxed: 1.625; /* --- Typography: Letter Spacing --- */ - --thunder-typography-letterSpacing-tight: -0.01em; - --thunder-typography-letterSpacing-normal: 0; - --thunder-typography-letterSpacing-wide: 0.025em; + --thunderid-typography-letterSpacing-tight: -0.01em; + --thunderid-typography-letterSpacing-normal: 0; + --thunderid-typography-letterSpacing-wide: 0.025em; /* --- Component: Button --- */ - --thunder-button-borderRadius: var(--thunder-border-radius-small); - --thunder-button-fontWeight: var(--thunder-typography-fontWeight-medium); - --thunder-button-sm-height: 30px; - --thunder-button-sm-paddingX: calc(var(--thunder-spacing-unit) * 1.25); - --thunder-button-sm-fontSize: var(--thunder-typography-fontSize-sm); - --thunder-button-md-height: 36px; - --thunder-button-md-paddingX: calc(var(--thunder-spacing-unit) * 2); - --thunder-button-md-fontSize: var(--thunder-typography-fontSize-md); - --thunder-button-lg-height: 42px; - --thunder-button-lg-paddingX: calc(var(--thunder-spacing-unit) * 2.5); - --thunder-button-lg-fontSize: var(--thunder-typography-fontSize-lg); + --thunderid-button-borderRadius: var(--thunderid-border-radius-small); + --thunderid-button-fontWeight: var(--thunderid-typography-fontWeight-medium); + --thunderid-button-sm-height: 30px; + --thunderid-button-sm-paddingX: calc(var(--thunderid-spacing-unit) * 1.25); + --thunderid-button-sm-fontSize: var(--thunderid-typography-fontSize-sm); + --thunderid-button-md-height: 36px; + --thunderid-button-md-paddingX: calc(var(--thunderid-spacing-unit) * 2); + --thunderid-button-md-fontSize: var(--thunderid-typography-fontSize-md); + --thunderid-button-lg-height: 42px; + --thunderid-button-lg-paddingX: calc(var(--thunderid-spacing-unit) * 2.5); + --thunderid-button-lg-fontSize: var(--thunderid-typography-fontSize-lg); /* --- Component: Input fields --- */ - --thunder-input-borderRadius: var(--thunder-border-radius-small); - --thunder-input-height: 36px; - --thunder-input-paddingX: calc(var(--thunder-spacing-unit) * 1.25); - --thunder-input-fontSize: var(--thunder-typography-fontSize-md); - --thunder-input-borderColor: var(--thunder-color-border); - --thunder-input-focusBorderColor: var(--thunder-color-primary-main); - --thunder-input-focusRing: 0 0 0 3px var(--thunder-focus-ring-color); + --thunderid-input-borderRadius: var(--thunderid-border-radius-small); + --thunderid-input-height: 36px; + --thunderid-input-paddingX: calc(var(--thunderid-spacing-unit) * 1.25); + --thunderid-input-fontSize: var(--thunderid-typography-fontSize-md); + --thunderid-input-borderColor: var(--thunderid-color-border); + --thunderid-input-focusBorderColor: var(--thunderid-color-primary-main); + --thunderid-input-focusRing: 0 0 0 3px var(--thunderid-focus-ring-color); /* --- Component: Card --- */ - --thunder-card-borderRadius: var(--thunder-border-radius-medium); - --thunder-card-padding: calc(var(--thunder-spacing-unit) * 2.5); - --thunder-card-shadow: var(--thunder-shadow-small); - --thunder-card-borderColor: var(--thunder-color-border); + --thunderid-card-borderRadius: var(--thunderid-border-radius-medium); + --thunderid-card-padding: calc(var(--thunderid-spacing-unit) * 2.5); + --thunderid-card-shadow: var(--thunderid-shadow-small); + --thunderid-card-borderColor: var(--thunderid-color-border); /* --- Component: Alert --- */ - --thunder-alert-borderRadius: var(--thunder-border-radius-small); - --thunder-alert-paddingX: calc(var(--thunder-spacing-unit) * 1.5); - --thunder-alert-paddingY: calc(var(--thunder-spacing-unit) * 1.25); + --thunderid-alert-borderRadius: var(--thunderid-border-radius-small); + --thunderid-alert-paddingX: calc(var(--thunderid-spacing-unit) * 1.5); + --thunderid-alert-paddingY: calc(var(--thunderid-spacing-unit) * 1.25); /* --- Component: Checkbox --- */ - --thunder-checkbox-size: 16px; + --thunderid-checkbox-size: 16px; /* --- Component: Avatar --- */ - --thunder-avatar-size: 64px; - --thunder-avatar-fontSize: 1.375rem; + --thunderid-avatar-size: 64px; + --thunderid-avatar-fontSize: 1.375rem; /* --- Component: Dropdown --- */ - --thunder-dropdown-borderRadius: var(--thunder-border-radius-medium); - --thunder-dropdown-shadow: var(--thunder-shadow-medium); - --thunder-dropdown-itemPaddingX: calc(var(--thunder-spacing-unit) * 1.5); - --thunder-dropdown-itemPaddingY: calc(var(--thunder-spacing-unit) * 1); + --thunderid-dropdown-borderRadius: var(--thunderid-border-radius-medium); + --thunderid-dropdown-shadow: var(--thunderid-shadow-medium); + --thunderid-dropdown-itemPaddingX: calc(var(--thunderid-spacing-unit) * 1.5); + --thunderid-dropdown-itemPaddingY: calc(var(--thunderid-spacing-unit) * 1); /* --- Component overrides (set by ThemeProvider when configured) --- */ - --thunder-component-button-root-borderRadius: var(--thunder-button-borderRadius); - --thunder-component-field-root-borderRadius: var(--thunder-input-borderRadius); + --thunderid-component-button-root-borderRadius: var(--thunderid-button-borderRadius); + --thunderid-component-field-root-borderRadius: var(--thunderid-input-borderRadius); } `; diff --git a/packages/vue/src/styles/injectStyles.ts b/packages/vue/src/styles/injectStyles.ts index 15fedbd1..f5622353 100644 --- a/packages/vue/src/styles/injectStyles.ts +++ b/packages/vue/src/styles/injectStyles.ts @@ -41,6 +41,9 @@ import SPINNER_CSS from '../components/primitives/Spinner/Spinner.css'; import TEXT_FIELD_CSS from '../components/primitives/TextField/TextField.css'; import TYPOGRAPHY_CSS from '../components/primitives/Typography/Typography.css'; +// Auth +import BASE_SIGN_IN_CSS from '../components/auth/sign-in/BaseSignIn.css'; + // Presentation /** @@ -68,6 +71,8 @@ const STYLES: string = [ DIVIDER_CSS, LOGO_CSS, SPINNER_CSS, + // Auth + BASE_SIGN_IN_CSS, // Presentation LANGUAGE_SWITCHER_CSS, USER_DROPDOWN_CSS, diff --git a/packages/vue/src/utils/resolveTranslationsInObject.ts b/packages/vue/src/utils/resolveTranslationsInObject.ts index 25415e10..58489e6c 100644 --- a/packages/vue/src/utils/resolveTranslationsInObject.ts +++ b/packages/vue/src/utils/resolveTranslationsInObject.ts @@ -16,7 +16,7 @@ type TranslationFn = (key: string, params?: Record) => export const resolveTranslationsInObject = >( obj: T, t: TranslationFn, - properties: string[] = ['label', 'placeholder', 'text', 'title', 'subtitle'], + properties: string[] = ['label', 'placeholder', 'text', 'title', 'subtitle', 'alt', 'src'], meta?: FlowMetadataResponse | null, ): T => { const resolved: T = {...obj}; diff --git a/samples/nextjs/quickstart/.env.example b/samples/nextjs/quickstart/.env.example index f30ce6ad..66cdaddf 100644 --- a/samples/nextjs/quickstart/.env.example +++ b/samples/nextjs/quickstart/.env.example @@ -15,3 +15,6 @@ NEXT_PUBLIC_THUNDERID_APPLICATION_ID=your-application-id-here NEXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin # Local app route that renders the sign-up page. Not from the console. NEXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup + +# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production. +NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/samples/nextjs/quickstart/app/layout.tsx b/samples/nextjs/quickstart/app/layout.tsx index d8172a5c..7340e107 100644 --- a/samples/nextjs/quickstart/app/layout.tsx +++ b/samples/nextjs/quickstart/app/layout.tsx @@ -18,11 +18,10 @@ const isRedirectFlow = Boolean(process.env.NEXT_PUBLIC_THUNDERID_CLIENT_ID) const REQUIRED_ENV_VARS = [ 'NEXT_PUBLIC_THUNDERID_BASE_URL', - 'THUNDERID_CLIENT_SECRET', 'THUNDERID_SECRET', ...(isRedirectFlow - ? ['NEXT_PUBLIC_THUNDERID_CLIENT_ID'] - : ['NEXT_PUBLIC_THUNDERID_APPLICATION_ID', 'NEXT_PUBLIC_THUNDERID_SIGN_IN_URL', 'NEXT_PUBLIC_THUNDERID_SIGN_UP_URL']), + ? ['NEXT_PUBLIC_THUNDERID_CLIENT_ID', 'THUNDERID_CLIENT_SECRET'] + : ['NEXT_PUBLIC_THUNDERID_APPLICATION_ID', 'NEXT_PUBLIC_THUNDERID_SIGN_IN_URL', 'NEXT_PUBLIC_THUNDERID_SIGN_UP_URL', 'THUNDERID_FLOW_SECRET']), ] export default function RootLayout({ diff --git a/samples/nuxt/quickstart/.env.example b/samples/nuxt/quickstart/.env.example index 73be597c..fbeed8e6 100644 --- a/samples/nuxt/quickstart/.env.example +++ b/samples/nuxt/quickstart/.env.example @@ -1,17 +1,14 @@ # Base URL of your ThunderID deployment. ex: https://localhost:8090 NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090 -# OAuth2 Client Secret for this app. Shown once when the application is created; -# if lost, regenerate it from the app's Credentials tab (under Edit). Server-only, never expose to the browser. -THUNDERID_CLIENT_SECRET=your-client-secret-here +# Flow Secret for this app. Sent in the `Flow-Secret` header to authenticate this app when the +# native (embedded) flow starts. Shown once when the application is created; if lost, regenerate it +# from the app's Credentials tab (under Edit). Server-only, never expose to the browser. +THUNDERID_FLOW_SECRET=your-flow-secret-here # Secret used to encrypt this app's session cookie. Generate locally, not from the console. THUNDERID_SESSION_SECRET=generate-with-openssl-rand-base64-32 -# ── Native flow (default) ─────────────────────────────────────────────── -# Sign-in/sign-up render inline on this app's own routes below, with no -# redirect to ThunderID's hosted pages. Requires the three vars below. - # Application ID (spId) for this app. ThunderID Console -> your application -> Overview. NUXT_PUBLIC_THUNDERID_APPLICATION_ID=your-application-id-here # Local app route that renders the sign-in page. Not from the console. diff --git a/samples/nuxt/quickstart/README.md b/samples/nuxt/quickstart/README.md index 530917e5..44dedd15 100644 --- a/samples/nuxt/quickstart/README.md +++ b/samples/nuxt/quickstart/README.md @@ -25,7 +25,7 @@ A minimal Nuxt 3 application demonstrating ThunderID authentication with OAuth 2 NUXT_PUBLIC_THUNDERID_APPLICATION_ID= NUXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin NUXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup - THUNDERID_CLIENT_SECRET= + THUNDERID_FLOW_SECRET= THUNDERID_SESSION_SECRET= ``` @@ -58,11 +58,15 @@ To send the user to ThunderID's hosted sign-in page instead, switch to the redir ```dotenv NUXT_PUBLIC_THUNDERID_CLIENT_ID= + THUNDERID_CLIENT_SECRET= ``` - `THUNDERID_CLIENT_SECRET` is already set from step 2 above and is reused as-is by both flows. To - switch back to the native flow, run `node scripts/prepare-dev.cjs --flow=native` (or manually - re-enable the native-flow vars and comment out the one above). -3. Fill in `NUXT_PUBLIC_THUNDERID_CLIENT_ID` in `.env`, then restart the dev server. + Both values come from the application's Credentials tab in the console. The redirect-based flow + doesn't use `THUNDERID_FLOW_SECRET` (that's only sent when the native flow starts), so it can stay + set in `.env` from step 2 above, unused. To switch back to the native flow, run + `node scripts/prepare-dev.cjs --flow=native` (or manually re-enable the native-flow vars and comment + out the two above). +3. Fill in `NUXT_PUBLIC_THUNDERID_CLIENT_ID` and `THUNDERID_CLIENT_SECRET` in `.env`, then restart the + dev server. diff --git a/samples/nuxt/quickstart/app/components/AppNav.vue b/samples/nuxt/quickstart/app/components/AppNav.vue index f7e7e3fc..d785170f 100644 --- a/samples/nuxt/quickstart/app/components/AppNav.vue +++ b/samples/nuxt/quickstart/app/components/AppNav.vue @@ -50,14 +50,14 @@ function toggleDark() { - - - - - + + + + + {{ isLoading ? 'Signing in…' : 'Sign in' }} - - + + diff --git a/samples/nuxt/quickstart/app/pages/index.vue b/samples/nuxt/quickstart/app/pages/index.vue index 3a5b9e70..fe0b7abb 100644 --- a/samples/nuxt/quickstart/app/pages/index.vue +++ b/samples/nuxt/quickstart/app/pages/index.vue @@ -91,7 +91,7 @@ onUnmounted(() => {
- +
- + {{ isLoading ? 'Signing in…' : 'Sign in' }} - +

@@ -137,9 +137,9 @@ onUnmounted(() => {
- + - +
@@ -229,6 +229,6 @@ onUnmounted(() => {
-
+ diff --git a/samples/nuxt/quickstart/app/pages/signin.vue b/samples/nuxt/quickstart/app/pages/signin.vue index 63f529f4..adcb418c 100644 --- a/samples/nuxt/quickstart/app/pages/signin.vue +++ b/samples/nuxt/quickstart/app/pages/signin.vue @@ -6,19 +6,19 @@ const dark = useState('dark', () => false)
- +

You're already signed in.

Go home
-
+ - +
- +
-
+
diff --git a/samples/nuxt/quickstart/app/pages/signup.vue b/samples/nuxt/quickstart/app/pages/signup.vue index 300153e1..6e8405e1 100644 --- a/samples/nuxt/quickstart/app/pages/signup.vue +++ b/samples/nuxt/quickstart/app/pages/signup.vue @@ -6,19 +6,19 @@ const dark = useState('dark', () => false)
- +

You're already signed in.

Go home
-
+ - +
- +
-
+
diff --git a/samples/nuxt/quickstart/scripts/prepare-dev.cjs b/samples/nuxt/quickstart/scripts/prepare-dev.cjs index 53a9e762..1121a5a2 100644 --- a/samples/nuxt/quickstart/scripts/prepare-dev.cjs +++ b/samples/nuxt/quickstart/scripts/prepare-dev.cjs @@ -8,9 +8,10 @@ const root = path.join(__dirname, '..'); const PREFIX = 'NUXT_PUBLIC_THUNDERID_'; const NATIVE_FLOW_VARS = [`${PREFIX}APPLICATION_ID`, `${PREFIX}SIGN_IN_URL`, `${PREFIX}SIGN_UP_URL`]; -const REDIRECT_FLOW_VARS = [`${PREFIX}CLIENT_ID`]; +const REDIRECT_FLOW_VARS = [`${PREFIX}CLIENT_ID`, 'THUNDERID_CLIENT_SECRET']; const REDIRECT_FLOW_PLACEHOLDERS = { [`${PREFIX}CLIENT_ID`]: 'your-client-id-here', + THUNDERID_CLIENT_SECRET: 'your-client-secret-here', }; const flowArg = process.argv.find((arg) => arg.startsWith('--flow='));