From ae8559f24ea17dce74b4db96bdb17f812d62066e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 16 Jan 2026 00:23:07 +1100 Subject: [PATCH] feat(registry): add Google Sign-In script - Add useScriptGoogleSignIn composable with full GIS API support - One Tap, personalized button, and automatic sign-in flows - FedCM API support (Privacy Sandbox, mandatory Aug 2025) - Moment notifications for tracking One Tap display state - Comprehensive TypeScript types for all GIS interfaces - Interactive docs demo with live sign-in testing - E2E test and playground example Co-Authored-By: Claude Opus 4.5 --- .../components/content/GoogleSignInDemo.vue | 248 ++++++++++++ .../content/scripts/utility/google-sign-in.md | 375 ++++++++++++++++++ docs/nuxt.config.ts | 8 + playground/nuxt.config.ts | 13 + .../google-sign-in/nuxt-scripts.vue | 229 +++++++++++ src/registry.ts | 11 + src/runtime/registry/google-sign-in.ts | 164 ++++++++ test/e2e/basic.test.ts | 27 ++ .../basic/pages/tpc/google-sign-in.vue | 73 ++++ 9 files changed, 1148 insertions(+) create mode 100644 docs/app/components/content/GoogleSignInDemo.vue create mode 100644 docs/content/scripts/utility/google-sign-in.md create mode 100644 playground/pages/third-parties/google-sign-in/nuxt-scripts.vue create mode 100644 src/runtime/registry/google-sign-in.ts create mode 100644 test/fixtures/basic/pages/tpc/google-sign-in.vue diff --git a/docs/app/components/content/GoogleSignInDemo.vue b/docs/app/components/content/GoogleSignInDemo.vue new file mode 100644 index 00000000..f896da28 --- /dev/null +++ b/docs/app/components/content/GoogleSignInDemo.vue @@ -0,0 +1,248 @@ + + + diff --git a/docs/content/scripts/utility/google-sign-in.md b/docs/content/scripts/utility/google-sign-in.md new file mode 100644 index 00000000..152d7ac0 --- /dev/null +++ b/docs/content/scripts/utility/google-sign-in.md @@ -0,0 +1,375 @@ +--- +title: Google Sign-In +description: Add Google Sign-In to your Nuxt app with One Tap and personalized button support. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/google-sign-in.ts + size: xs +- label: Google Identity Services + icon: i-simple-icons-google + to: https://developers.google.com/identity/gsi/web/guides/overview + size: xs +--- + +[Google Sign-In](https://developers.google.com/identity/gsi/web) provides a secure and convenient way for users to sign in to your app using their Google Account with One Tap, personalized buttons, and automatic sign-in. + +Nuxt Scripts provides a registry script composable `useScriptGoogleSignIn` to easily integrate Google Sign-In in your Nuxt app with optimal performance. + +## Live Demo + +::google-sign-in-demo +:: + +## Performance Optimizations + +The script is loaded with `async` and `defer` attributes to prevent render-blocking, ensuring optimal Core Web Vitals. The initialization is deferred until you explicitly call `initialize()`, giving you full control over when One Tap appears. + +## Nuxt Config Setup + +The simplest way to load Google Sign-In globally in your Nuxt App is to use Nuxt config. Alternatively you can directly use the [useScriptGoogleSignIn](#usescriptgooglesignin) composable. + +::code-group + +```ts [Always enabled] +export default defineNuxtConfig({ + scripts: { + registry: { + googleSignIn: { + clientId: 'YOUR_GOOGLE_CLIENT_ID' + } + } + } +}) +``` + +```ts [Production only] +export default defineNuxtConfig({ + $production: { + scripts: { + registry: { + googleSignIn: { + clientId: 'YOUR_GOOGLE_CLIENT_ID' + } + } + } + } +}) +``` + +:: + +### With Environment Variables + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + googleSignIn: true, + } + }, + runtimeConfig: { + public: { + scripts: { + googleSignIn: { + clientId: '', // NUXT_PUBLIC_SCRIPTS_GOOGLE_SIGN_IN_CLIENT_ID + }, + }, + }, + }, +}) +``` + +## useScriptGoogleSignIn + +The `useScriptGoogleSignIn` composable lets you have fine-grained control over when and how you load Google Sign-In. + +```ts +const { onLoaded } = useScriptGoogleSignIn({ + clientId: 'YOUR_GOOGLE_CLIENT_ID' +}) + +// Initialize when ready +onLoaded(({ accounts }) => { + accounts.id.initialize({ + client_id: 'YOUR_GOOGLE_CLIENT_ID', + callback: handleCredentialResponse + }) +}) +``` + +Please follow the [Registry Scripts](/docs/guides/registry-scripts) guide to learn more about advanced usage. + +### GoogleSignInApi + +```ts +export interface GoogleSignInApi { + accounts: { + id: { + initialize: (config: IdConfiguration) => void + prompt: (momentListener?: (notification: MomentNotification) => void) => void + renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void + disableAutoSelect: () => void + cancel: () => void + revoke: (hint: string, callback: (response: RevocationResponse) => void) => void + } + } +} +``` + +### Config Schema + +You can configure the Google Sign-In script with the following options: + +```ts +export const GoogleSignInOptions = object({ + clientId: string(), + autoSelect: optional(boolean()), + context: optional(union([literal('signin'), literal('signup'), literal('use')])), + useFedcmForPrompt: optional(boolean()), + cancelOnTapOutside: optional(boolean()), + uxMode: optional(union([literal('popup'), literal('redirect')])), + loginUri: optional(string()), + itpSupport: optional(boolean()), + allowedParentOrigin: optional(union([string(), array(string())])), + hd: optional(string()), // Restrict to Google Workspace domain +}) +``` + +## Example + +### One Tap Sign-In + +The One Tap prompt provides a streamlined sign-in experience: + +```vue + +``` + +### Personalized Button + +Render Google's personalized Sign in with Google button: + +```vue + + + +``` + +## Moment Notifications + +Track the One Tap display state: + +```ts +const { onLoaded } = useScriptGoogleSignIn() + +onLoaded(({ accounts }) => { + accounts.id.prompt((notification) => { + if (notification.isDisplayMoment()) { + if (notification.isDisplayed()) { + console.log('One Tap displayed') + } else { + console.log('Not displayed:', notification.getNotDisplayedReason()) + } + } + + if (notification.isSkippedMoment()) { + console.log('Skipped:', notification.getSkippedReason()) + } + + if (notification.isDismissedMoment()) { + console.log('Dismissed:', notification.getDismissedReason()) + } + }) +}) +``` + +## Server-Side Verification + +Always verify the credential token on your server: + +```ts [server/api/auth/google.post.ts] +export default defineEventHandler(async (event) => { + const { credential } = await readBody(event) + + // Verify the token with Google + const response = await $fetch(`https://oauth2.googleapis.com/tokeninfo`, { + params: { id_token: credential } + }) + + // Verify the client ID matches + if (response.aud !== 'YOUR_CLIENT_ID') { + throw createError({ statusCode: 401, message: 'Invalid token' }) + } + + // Create session with user info + const user = { + email: response.email, + name: response.name, + picture: response.picture, + sub: response.sub + } + + return { user } +}) +``` + +## FedCM API Support + +Enable Privacy Sandbox [FedCM API](https://developers.google.com/identity/gsi/web/guides/fedcm-migration) support for enhanced privacy. **FedCM adoption becomes mandatory in August 2025.** + +```ts +export default defineNuxtConfig({ + scripts: { + registry: { + googleSignIn: { + clientId: 'YOUR_CLIENT_ID', + useFedcmForPrompt: true + } + } + } +}) +``` + +### Cross-Origin Iframes + +When using One Tap or the Sign-In button in cross-origin iframes with FedCM, add the `allow` attribute to all parent iframes: + +```html + +``` + +::warning +With FedCM enabled, customizing the One Tap prompt position via `prompt_parent_id` is not supported. +:: + +## Revoking Access + +Allow users to revoke access to their Google Account: + +```ts +const { onLoaded } = useScriptGoogleSignIn() + +function revokeAccess(userId: string) { + onLoaded(({ accounts }) => { + accounts.id.revoke(userId, (response) => { + if (response.successful) { + console.log('Access revoked') + } else { + console.error('Revocation failed:', response.error) + } + }) + }) +} +``` + +## Best Practices + +### Logout Handling + +Always call `disableAutoSelect()` when the user signs out to prevent automatic re-authentication: + +```ts +function signOut() { + // Clear your app's session + user.value = null + + // Prevent One Tap from auto-selecting this account + onLoaded(({ accounts }) => { + accounts.id.disableAutoSelect() + }) +} +``` + +### Hosted Domain Restriction + +Restrict sign-in to a specific Google Workspace domain: + +```ts +accounts.id.initialize({ + client_id: 'YOUR_CLIENT_ID', + callback: handleCredentialResponse, + hd: 'your-company.com' // Only allow users from this domain +}) +``` + +## Local Development Setup + +To test Google Sign-In locally: + +1. Go to [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials) +2. Create or select an OAuth 2.0 Client ID (Web application type) +3. Under **Authorized JavaScript origins**, add: + - `http://localhost` + - `http://localhost:3000` (or your dev server port) +4. Save and copy your Client ID + +::note +Google requires `http://localhost` (not `127.0.0.1`) for local development. No redirect URI is needed when using popup mode. +:: + +Then configure your environment: + +```bash [.env] +NUXT_PUBLIC_SCRIPTS_GOOGLE_SIGN_IN_CLIENT_ID=your-client-id.apps.googleusercontent.com +``` + +## Guides + +::note +For more detailed info on how to obtain a Google Client ID and configure your OAuth consent screen, see the official [Google Identity Services documentation](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid). +:: diff --git a/docs/nuxt.config.ts b/docs/nuxt.config.ts index 78057627..f6b66d97 100644 --- a/docs/nuxt.config.ts +++ b/docs/nuxt.config.ts @@ -137,6 +137,14 @@ export default defineNuxtConfig({ zeroRuntime: true, }, + scripts: { + registry: { + googleSignIn: { + clientId: '1035629894173-c0rpj3bqmcgsi8r8r08hh0kej3cpmikv.apps.googleusercontent.com', + }, + }, + }, + seo: { meta: { googleSiteVerification: 'y3acjlg66w6e8QRmX-asCZv9EBpyLHdrhIKzdXJvqDg', diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 9c70b854..f810656a 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -13,6 +13,16 @@ export default defineNuxtConfig({ css: ['~/assets/css/main.css'], + runtimeConfig: { + public: { + scripts: { + googleSignIn: { + clientId: '', // NUXT_PUBLIC_SCRIPTS_GOOGLE_SIGN_IN_CLIENT_ID + }, + }, + }, + }, + nitro: { prerender: { failOnError: false, @@ -35,5 +45,8 @@ export default defineNuxtConfig({ scripts: { debug: true, + registry: { + googleSignIn: true, + }, }, }) diff --git a/playground/pages/third-parties/google-sign-in/nuxt-scripts.vue b/playground/pages/third-parties/google-sign-in/nuxt-scripts.vue new file mode 100644 index 00000000..3d9d1e4b --- /dev/null +++ b/playground/pages/third-parties/google-sign-in/nuxt-scripts.vue @@ -0,0 +1,229 @@ + + + diff --git a/src/registry.ts b/src/registry.ts index cb05b93f..06336d36 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -334,6 +334,17 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption return `${baseUrl}/${options?.enterprise ? 'enterprise.js' : 'api.js'}` }, }, + { + label: 'Google Sign-In', + src: 'https://accounts.google.com/gsi/client', + scriptBundling: false, // CORS prevents bundling + category: 'utility', + logo: ``, + import: { + name: 'useScriptGoogleSignIn', + from: await resolve('./runtime/registry/google-sign-in'), + }, + }, { label: 'Google Tag Manager', category: 'tracking', diff --git a/src/runtime/registry/google-sign-in.ts b/src/runtime/registry/google-sign-in.ts new file mode 100644 index 00000000..696b7b3b --- /dev/null +++ b/src/runtime/registry/google-sign-in.ts @@ -0,0 +1,164 @@ +import { useRegistryScript } from '#nuxt-scripts/utils' +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { object, string, optional, boolean, array, union, literal } from '#nuxt-scripts-validator' + +// Credential response from One Tap or button flow +export interface CredentialResponse { + credential: string // JWT token + select_by: 'auto' | 'user' | 'user_1tap' | 'user_2tap' | 'btn' | 'btn_confirm' | 'btn_add_session' | 'btn_confirm_add_session' + clientId?: string +} + +// Google Accounts ID configuration +export interface IdConfiguration { + client_id: string + auto_select?: boolean + callback?: (response: CredentialResponse) => void + login_uri?: string + native_callback?: (response: CredentialResponse) => void + cancel_on_tap_outside?: boolean + prompt_parent_id?: string + nonce?: string + context?: 'signin' | 'signup' | 'use' + state_cookie_domain?: string + ux_mode?: 'popup' | 'redirect' + allowed_parent_origin?: string | string[] + intermediate_iframe_close_callback?: () => void + itp_support?: boolean + login_hint?: string + hd?: string + use_fedcm_for_prompt?: boolean +} + +// Button configuration for personalized button +export interface GsiButtonConfiguration { + type?: 'standard' | 'icon' + theme?: 'outline' | 'filled_blue' | 'filled_black' + size?: 'large' | 'medium' | 'small' + text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin' + shape?: 'rectangular' | 'pill' | 'circle' | 'square' + logo_alignment?: 'left' | 'center' + width?: string | number + locale?: string + click_listener?: () => void + // FedCM support for button flow (mandatory from August 2025) + use_fedcm?: boolean +} + +// Moment notification types +export type MomentType + = | 'display' + | 'skipped' + | 'dismissed' + +export interface MomentNotification { + isDisplayMoment: () => boolean + isDisplayed: () => boolean + isNotDisplayed: () => boolean + getNotDisplayedReason: () => + | 'browser_not_supported' + | 'invalid_client' + | 'missing_client_id' + | 'opt_out_or_no_session' + | 'secure_http_required' + | 'suppressed_by_user' + | 'unregistered_origin' + | 'unknown_reason' + isSkippedMoment: () => boolean + getSkippedReason: () => + | 'auto_cancel' + | 'user_cancel' + | 'tap_outside' + | 'issuing_failed' + isDismissedMoment: () => boolean + getDismissedReason: () => + | 'credential_returned' + | 'cancel_called' + | 'flow_restarted' + getMomentType: () => MomentType +} + +// Revocation response +export interface RevocationResponse { + successful: boolean + error?: string +} + +// Use namespace declaration like google-maps to avoid conflicts +// eslint-disable-next-line +declare namespace google { + // eslint-disable-next-line @typescript-eslint/no-namespace + export namespace accounts { + // eslint-disable-next-line @typescript-eslint/no-namespace + export namespace id { + export function initialize(config: IdConfiguration): void + export function prompt(momentListener?: (notification: MomentNotification) => void): void + export function renderButton(parent: HTMLElement, options: GsiButtonConfiguration): void + export function disableAutoSelect(): void + export function cancel(): void + export function revoke(hint: string, callback: (response: RevocationResponse) => void): void + } + } +} + +type AccountsNamespace = typeof google.accounts +export interface GoogleSignInApi { + accounts: AccountsNamespace +} + +export const GoogleSignInOptions = object({ + clientId: string(), + // Auto-select credentials if only one is available + autoSelect: optional(boolean()), + // Context for One Tap (signin, signup, or use) + context: optional(union([literal('signin'), literal('signup'), literal('use')])), + // FedCM API support (Privacy Sandbox) - mandatory from August 2025 + useFedcmForPrompt: optional(boolean()), + // Cancel One Tap if user clicks outside + cancelOnTapOutside: optional(boolean()), + // UX mode: popup or redirect + uxMode: optional(union([literal('popup'), literal('redirect')])), + // Login URI for redirect flow + loginUri: optional(string()), + // ITP (Intelligent Tracking Prevention) support + itpSupport: optional(boolean()), + // Allowed parent origins for iframe embedding + allowedParentOrigin: optional(union([string(), array(string())])), + // Hosted domain - restrict to specific Google Workspace domain + hd: optional(string()), +}) + +export type GoogleSignInInput = RegistryScriptInput + +export function useScriptGoogleSignIn(_options?: GoogleSignInInput) { + return useRegistryScript(_options?.key || 'googleSignIn', () => { + return { + scriptInput: { + src: 'https://accounts.google.com/gsi/client', + // Performance best practice: async + defer to prevent render blocking + defer: true, + // Google's script doesn't support CORS + crossorigin: false, + }, + schema: import.meta.dev ? GoogleSignInOptions : undefined, + scriptOptions: { + use() { + return { + accounts: (window as any).google?.accounts as AccountsNamespace, + } + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + // Initialize minimal window.google namespace + // The actual initialization happens via the initialize() method + // to give developers control over when One Tap appears + const w = window as any + w.google = w.google || {} + w.google.accounts = w.google.accounts || {} + w.google.accounts.id = w.google.accounts.id || {} + }, + } + }, _options) +} diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index ff5011e2..50437349 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -252,4 +252,31 @@ describe('third-party-capital', () => { // eslint-disable-next-line no-console console.log('Feature flag payload:', featureFlagPayload) }) + + it.todo('expect Google Sign-In to load and render button - requires network access to Google', async () => { + const { page } = await createPage('/tpc/google-sign-in') + + // wait for client hydration + await page.waitForSelector('#status', { timeout: 5000 }) + + // wait for script to load (Google's script can be slow) + await page.waitForFunction(() => { + const status = document.querySelector('#status')?.textContent?.trim() + return status === 'loaded' + }, { timeout: 20000 }) + + // verify button was rendered + await page.waitForSelector('#button-rendered', { timeout: 5000 }) + const buttonRendered = await page.$eval('#button-rendered', el => el.textContent?.trim()) + expect(buttonRendered).toBe('true') + + // verify window.google.accounts.id is available + const hasGoogleApi = await page.evaluate(() => { + return typeof window.google !== 'undefined' + && typeof window.google.accounts !== 'undefined' + && typeof window.google.accounts.id !== 'undefined' + && typeof window.google.accounts.id.initialize === 'function' + }) + expect(hasGoogleApi).toBe(true) + }) }) diff --git a/test/fixtures/basic/pages/tpc/google-sign-in.vue b/test/fixtures/basic/pages/tpc/google-sign-in.vue new file mode 100644 index 00000000..707f9673 --- /dev/null +++ b/test/fixtures/basic/pages/tpc/google-sign-in.vue @@ -0,0 +1,73 @@ + + +