From 87bf9a6151150ef4e4ed7e0aec6973c8e010058c Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 23 Aug 2026 05:31:02 +0000 Subject: [PATCH] Fix Google sign-in for existing email and ticket-buyer accounts. Google sign-in only worked for people who already had a linked google row in auth_accounts. Anyone who first appeared another way -- a guest ticket purchase, or an email/password signup made after the Better Auth migration -- got a 401 "account not linked". trustedProviders: ['google'] defeats only one of better-auth's two linking gates. The second, requireLocalEmailVerified, defaults to true and refuses the link whenever the LOCAL users.email_verified is false, independently of whether the provider is trusted. That flag is false for every guest-booking row and for every post-migration signup, since requireEmailVerification is off and no verification mail is sent. Turn that gate off: the Google id_token is signature-verified against Google's JWKS with issuer/audience/max-age checks and carries its own email_verified, so the local column proves nothing extra here. Linking alone was not enough. getAuthUser() rejects any session whose user is not 'active', so a ticket buyer would link Google, receive a cookie, and still look logged out. A databaseHooks.account.create.after hook now promotes unclaimed rows to claimed/active when a google account is attached, scoped in the WHERE clause so a suspended account is never reactivated this way. Also normalize users.email. The unique index is case-sensitive while better-auth lowercases every lookup, so someone who booked as John@Gmail.com was invisible to sign-in and Google minted a SECOND user row, stranding their tickets on the first. normalizeEmail() covers the find-or-create sites in tickets.ts and door.ts plus the claim-eligibility lookup, and an idempotent migration lowercases existing rows -- skipping any that would collide and reporting those for manual merge, since merging two people's tickets and payments is not a migration's call. tickets.attendeeEmail still stores the address exactly as typed. Tests drive the real signInSocial id-token path with Google stubbed by signing tokens with a throwaway RS256 key and serving our own JWKS, so the actual verification runs without network or credentials. That also makes the deprecation risk loud: requireLocalEmailVerified is marked for removal upstream, and an upgrade that drops it now fails CI instead of silently locking ticket buyers out again. Frontend carries error.code through so OAUTH_LINK_ERROR renders an actionable message in both locales rather than a bare "account not linked". Co-Authored-By: Claude Opus 5 --- backend/src/db/migrate.ts | 55 ++++- .../src/lib/betterAuth.integration.test.ts | 228 +++++++++++++++++- backend/src/lib/betterAuth.ts | 58 +++++ backend/src/lib/utils.ts | 13 + backend/src/routes/authExt.ts | 5 +- backend/src/routes/door.ts | 13 +- backend/src/routes/tickets.ts | 25 +- .../src/components/GoogleSignInButton.tsx | 20 +- frontend/src/context/AuthContext.tsx | 7 +- 9 files changed, 402 insertions(+), 22 deletions(-) diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 2f0060b..a08fbfe 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,6 +1,6 @@ import 'dotenv/config'; -import { db, dbAll, events } from './index.js'; -import { sql, eq } from 'drizzle-orm'; +import { db, dbAll, dbGet, events, users } from './index.js'; +import { sql, eq, ne } from 'drizzle-orm'; import { uniqueSlug } from '../lib/slugify.js'; const dbType = process.env.DB_TYPE || 'sqlite'; @@ -1344,6 +1344,57 @@ async function migrate() { `); } + // ==================== users.email normalization ==================== + // Better Auth lowercases the address on every lookup and write it performs, + // but the users.email unique index is case-sensitive on both dialects. Rows + // written outside Better Auth (guest bookings, door sales, admin-added + // tickets) used to keep the address exactly as typed, so a buyer who entered + // "John@Gmail.com" was invisible to sign-in and to Google account linking: + // signing in with Google minted a SECOND user row and left their tickets + // stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this + // fixes the rows already in the table. + // + // Idempotent, and deliberately conservative: a row is only lowercased when + // nothing already occupies the lowercase address. A genuine collision means + // two user rows for the same person, each with its own tickets, invoices and + // payments — merging those is a judgement call, not a migration, so they are + // reported for manual review instead. + const lowercaseEmailsSql = ` + UPDATE users SET email = LOWER(email) + WHERE email <> LOWER(email) + AND NOT EXISTS ( + SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email) + ) + `; + if (dbType === 'sqlite') { + await (db as any).run(sql.raw(lowercaseEmailsSql)); + } else { + await (db as any).execute(sql.raw(lowercaseEmailsSql)); + } + + // Whatever still differs from its own lowercase form is exactly the set the + // UPDATE refused to touch, i.e. the collisions. + const collisions = await dbAll<{ id: string; email: string }>( + (db as any) + .select({ id: (users as any).id, email: (users as any).email }) + .from(users) + .where(ne((users as any).email, sql`LOWER(${(users as any).email})`)) + ); + if (collisions.length > 0) { + console.warn( + `WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` + + `lowercase address is already taken. Sign-in and Google linking only ever reach ` + + `the lowercase row, so these need a manual merge:` + ); + for (const row of collisions) { + const canonical = row.email.toLowerCase(); + const existing = await dbGet<{ id: string }>( + (db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical)) + ); + console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`); + } + } + // Backfill slugs for any events that don't have one yet (shared across DB types). // Ordered by creation so duplicate titles get deterministic -2, -3 suffixes. const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>( diff --git a/backend/src/lib/betterAuth.integration.test.ts b/backend/src/lib/betterAuth.integration.test.ts index a976a00..afaa013 100644 --- a/backend/src/lib/betterAuth.integration.test.ts +++ b/backend/src/lib/betterAuth.integration.test.ts @@ -3,6 +3,8 @@ import { execFileSync } from 'child_process'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { normalizeEmail } from './utils.js'; // Environment must be pinned BEFORE the db/betterAuth singletons are imported // (dotenv never overrides pre-set values). @@ -13,7 +15,13 @@ process.env.DATABASE_URL = dbPath; process.env.FRONTEND_URL = 'http://localhost:3002'; process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef'; delete process.env.REDIS_URL; // memory lockout/rate-limit backends -delete process.env.GOOGLE_CLIENT_ID; + +// Google IS configured here: account linking is the whole point of the tests at +// the bottom of this file, and betterAuth.ts omits `socialProviders` entirely +// when this is unset. No real credentials are involved — the id tokens are +// signed with a throwaway keypair and Google's JWKS endpoint is stubbed below. +const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com'; +process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID; // Capture outgoing auth emails (magic links, password resets) const sentEmails: Array<{ to: string; subject: string; html: string }> = []; @@ -41,6 +49,85 @@ function extractToken(html: string, param = 'token'): string { return decodeURIComponent(match![1]); } +// ---- Google Identity Services stub ------------------------------------- +// verifyGoogleIdToken() checks signature, issuer, audience and max age against +// Google's published JWKS; its only network call is that JWKS fetch. Signing +// with our own key and serving our own JWKS exercises the real verification +// path without touching the network or needing OAuth credentials. +const GOOGLE_KID = 'spanglish-test-key'; +let googlePrivateKey: CryptoKey; + +async function installGoogleStub() { + const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true }); + googlePrivateKey = privateKey as CryptoKey; + const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' }; + + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: any, init?: any) => { + const url = typeof input === 'string' ? input : (input?.url ?? String(input)); + if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) { + return new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return realFetch(input, init); + }) as typeof fetch; +} + +function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) { + return new SignJWT({ + email: opts.email, + email_verified: opts.emailVerified ?? true, + name: opts.name ?? 'Google User', + picture: 'https://example.test/avatar.png', + }) + .setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID }) + .setIssuer('https://accounts.google.com') + .setAudience(GOOGLE_CLIENT_ID) + .setSubject(opts.sub) + .setIssuedAt() + .setExpirationTime('10m') + .sign(googlePrivateKey); +} + +async function signInWithGoogle( + opts: Parameters[0], + returnHeaders = false +): Promise { + const token = await googleIdToken(opts); + return auth.api.signInSocial({ + body: { provider: 'google', idToken: { token } }, + headers: new Headers(), + ...(returnHeaders ? { returnHeaders: true } : {}), + } as any); +} + +/** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts): + * unclaimed, unverified, and with no auth_accounts row at all. */ +function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') { + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at) + VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)` + ) + .run(id, email, name, now, now); + return id; +} + +function userRow(email: string) { + return sqlite + .prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?') + .get(email); +} + +function googleAccountsFor(userId: string) { + return sqlite + .prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'") + .all(userId); +} + function cookieHeaders(setCookie: string | null): Headers { const sessionPart = (setCookie || '') .split(/,(?=[^ ;]+=)/) @@ -60,6 +147,7 @@ beforeAll(() => { ({ db } = await import('../db/index.js')); const Database = (await import('better-sqlite3')).default; sqlite = new Database(dbPath); + await installGoogleStub(); })(); }, 120_000); @@ -291,3 +379,141 @@ describe('Better Auth integration', () => { expect(days).toBeLessThan(7.5); }); }); + + +describe('Google sign-in and account linking', () => { + it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => { + const id = insertBookingUser('booking-user-1', 'buyer@test.py'); + expect(userRow('buyer@test.py').email_verified).toBe(0); + + const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' }); + + expect(res.user.id).toBe(id); + expect(googleAccountsFor(id)).toHaveLength(1); + expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1); + }); + + it('claims the booking account so the resulting session is actually accepted', async () => { + // getAuthUser() (lib/auth.ts) rejects any session whose user is not + // 'active', so linking alone would leave the user looking logged out. + const id = insertBookingUser('booking-user-2', 'buyer2@test.py'); + + const { headers, response } = await signInWithGoogle( + { email: 'buyer2@test.py', sub: 'google-sub-buyer2' }, + true + ); + expect(response.user.id).toBe(id); + + const row = userRow('buyer2@test.py'); + expect(row.account_status).toBe('active'); + expect(row.is_claimed).toBe(1); + expect(row.email_verified).toBe(1); + + const session = await auth.api.getSession({ + headers: cookieHeaders(headers.get('set-cookie')), + }); + expect(session?.user.id).toBe(id); + expect((session?.user as any).accountStatus).toBe('active'); + }); + + it('never reactivates a suspended account through a Google link', async () => { + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at) + VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)` + ) + .run('suspended-google', 'suspended-google@test.py', now, now); + + await expect( + signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' }) + ).rejects.toThrow(); + expect(userRow('suspended-google@test.py').account_status).toBe('suspended'); + }); + + it('links Google onto an email/password account created after the Better Auth migration', async () => { + // Better Auth writes email_verified = 0 on sign-up (requireEmailVerification + // is off), which used to be enough to block linking on its own. + await auth.api.signUpEmail({ + body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' }, + }); + expect(userRow('pwuser@test.py').email_verified).toBe(0); + const id = userRow('pwuser@test.py').id; + + const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' }); + + expect(res.user.id).toBe(id); + expect(googleAccountsFor(id)).toHaveLength(1); + // The credential account survives: they can still sign in with a password. + const after = await auth.api.signInEmail({ + body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' }, + }); + expect(after.user.id).toBe(id); + }); + + it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => { + const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' }); + const row = userRow('fresh@test.py'); + expect(row.id).toBe(first.user.id); + expect(row.email_verified).toBe(1); + expect(row.account_status).toBe('active'); + + const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' }); + expect(second.user.id).toBe(first.user.id); + expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1); + expect(googleAccountsFor(first.user.id)).toHaveLength(1); + }); + + it('rejects an id token minted for a different client id', async () => { + const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' }) + .setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID }) + .setIssuer('https://accounts.google.com') + .setAudience('some-other-app.apps.googleusercontent.com') + .setSubject('google-sub-forged') + .setIssuedAt() + .setExpirationTime('10m') + .sign(googlePrivateKey); + + await expect( + auth.api.signInSocial({ + body: { provider: 'google', idToken: { token } }, + headers: new Headers(), + } as any) + ).rejects.toThrow(); + expect(userRow('forged@test.py')).toBeUndefined(); + }); +}); + +describe('users.email normalization', () => { + it('normalizes an address to its canonical stored form', () => { + expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com'); + }); + + it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => { + const now = new Date().toISOString(); + const insert = (id: string, email: string, status = 'unclaimed') => + sqlite + .prepare( + `INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at) + VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)` + ) + .run(id, email, status, now, now); + + insert('legacy-mixed', 'John@Example.com'); + // A pair that genuinely collides: the migration must leave both alone. + insert('legacy-dup-lower', 'dup@example.com'); + insert('legacy-dup-mixed', 'Dup@Example.com'); + + // The backfill lives in migrate.ts and is idempotent, so just re-run it. + execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' }); + + expect(userRow('john@example.com').id).toBe('legacy-mixed'); + expect(userRow('John@Example.com')).toBeUndefined(); + expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed'); + expect(userRow('dup@example.com').id).toBe('legacy-dup-lower'); + + // ...and the lowercased row is now reachable by Google sign-in. + const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' }); + expect(res.user.id).toBe('legacy-mixed'); + }, 120_000); +}); diff --git a/backend/src/lib/betterAuth.ts b/backend/src/lib/betterAuth.ts index df5eacf..998711e 100644 --- a/backend/src/lib/betterAuth.ts +++ b/backend/src/lib/betterAuth.ts @@ -209,6 +209,25 @@ export const auth = betterAuth({ // Google verifies email ownership, so linking by email is safe — this // matches the legacy /api/auth/google auto-link behavior. trustedProviders: ['google'], + // `trustedProviders` alone is NOT enough: better-auth ORs a second, + // independent gate — `requireLocalEmailVerified` (default true) — which + // refuses the link whenever the LOCAL users.email_verified is false. + // That is the state of every guest-booking user (routes/tickets.ts, + // routes/door.ts insert email_verified = false) and of every + // email/password signup made after the Better Auth migration, so Google + // sign-in failed for them with "account not linked". + // + // The local flag adds nothing here: the Google ID token is signature- + // verified against Google's JWKS with issuer/audience/max-age checks and + // carries its own `email_verified`, so Google — not our column — is what + // proves ownership of the address. + // + // NOTE: upstream marks this option deprecated ("the gate will become + // unconditional"). better-auth is pinned exactly at 1.6.25 in both + // workspaces, and betterAuth.integration.test.ts covers this path, so an + // upgrade that drops the option fails CI rather than silently locking + // ticket buyers out again. + requireLocalEmailVerified: false, }, }, @@ -246,6 +265,45 @@ export const auth = betterAuth({ }, }, }, + account: { + create: { + // Fires for both branches of the OAuth path: createOAuthUser (new user) + // and linkAccount (existing user), since both go through the adapter's + // createWithHooks(..., 'account'). + after: async (account) => { + if (account.providerId !== 'google') return; + // Attaching a Google account proves ownership of the address, so a + // row created during guest booking is now a real, claimed account. + // Without this, getAuthUser() (lib/auth.ts) rejects the brand-new + // session because accountStatus is still 'unclaimed' — the user gets + // a cookie and still looks logged out. Mirrors the tail of the + // magic-link claim flow in routes/authExt.ts. + // + // Scoped to 'unclaimed' in the WHERE clause so a suspended account is + // never silently reactivated by linking Google to it. + try { + await (db as any) + .update(authUsers) + // `emailVerified` is deliberately left alone: better-auth's link + // branch sets it right after this hook, but only when Google's + // id_token actually asserted email_verified. + .set({ + isClaimed: true, + accountStatus: 'active', + updatedAt: new Date(), + }) + .where( + and( + eq((authUsers as any).id, account.userId), + eq((authUsers as any).accountStatus, 'unclaimed') + ) + ); + } catch (err: any) { + console.error('[auth] Failed to claim account on Google link:', err?.message || err); + } + }, + }, + }, }, hooks: { diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts index 103ded7..7736ffc 100644 --- a/backend/src/lib/utils.ts +++ b/backend/src/lib/utils.ts @@ -22,6 +22,19 @@ export function generateTicketCode(): string { return `TKT-${nanoid(8).toUpperCase()}`; } +/** + * Canonical form for `users.email`. + * + * Better Auth lowercases the address on every lookup and write it performs, + * but the `users.email` unique index is case-sensitive on both dialects. Any + * row written outside Better Auth (guest bookings, door sales, admin-added + * tickets) must therefore be normalized the same way, or the row becomes + * invisible to sign-in / Google linking and a duplicate person gets created. + */ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + /** * Get current timestamp in the format appropriate for the database type. * - SQLite: returns ISO string diff --git a/backend/src/routes/authExt.ts b/backend/src/routes/authExt.ts index f834017..ea545c8 100644 --- a/backend/src/routes/authExt.ts +++ b/backend/src/routes/authExt.ts @@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'; import { auth } from '../lib/betterAuth.js'; import { validatePassword } from '../lib/passwordPolicy.js'; import { db, dbGet, users } from '../db/index.js'; -import { getNow, toDbBool } from '../lib/utils.js'; +import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; // Custom auth flows that Better Auth doesn't provide out of the box. Mounted @@ -83,8 +83,9 @@ authExt.get('/claim-eligibility', authExtRateLimit, async (c) => { return c.json({ canClaim: false }); } + // Normalized to match how the row is stored (see lib/utils.ts normalizeEmail) const user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, email)) + (db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email))) ); const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended' diff --git a/backend/src/routes/door.ts b/backend/src/routes/door.ts index cea7ad2..d856e0f 100644 --- a/backend/src/routes/door.ts +++ b/backend/src/routes/door.ts @@ -23,7 +23,7 @@ import { db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, } from '../db/index.js'; import { requireAuth } from '../lib/auth.js'; -import { generateId, generateTicketCode, getNow, toDbBool, toDbDate } from '../lib/utils.js'; +import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js'; import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js'; import { seatHolderCountQuery } from '../lib/capacity.js'; import { @@ -370,9 +370,14 @@ doorRouter.post( // No email is the fast path; a placeholder keeps the users.email unique // constraint satisfied without ever mailing anyone. - const accountEmail = hasEmail - ? attendee.email!.trim() - : `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`; + // Normalized: Better Auth lowercases every lookup it makes, and the + // users.email unique index is case-sensitive, so a mixed-case address + // written here would be invisible to sign-in and Google linking. + const accountEmail = normalizeEmail( + hasEmail + ? attendee.email! + : `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local` + ); let user = hasEmail ? await dbGet((db as any).select().from(users).where(eq((users as any).email, accountEmail))) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index ea3ad38..3333b59 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js'; import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; +import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; import emailService from '../lib/email.js'; @@ -148,9 +148,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { }, 400); } - // Find or create user + // Find or create user. The account row is keyed on the normalized address so + // it stays reachable from Better Auth (which lowercases every lookup) — + // tickets.attendeeEmail below keeps the address exactly as the buyer typed it. + const accountEmail = normalizeEmail(data.email); let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, data.email)) + (db as any).select().from(users).where(eq((users as any).email, accountEmail)) ); const now = getNow(); @@ -163,7 +166,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { const userId = generateId(); user = { id: userId, - email: data.email, + email: accountEmail, password: null, // No password for guest bookings; set on claim (Better Auth credential account) name: fullName, phone: data.phone || null, @@ -1425,9 +1428,10 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) ? data.email.trim() : `door-${generateId()}@doorentry.local`; - // Find or create user + // Find or create user (see the note on `accountEmail` in the booking route) + const accountEmail = normalizeEmail(attendeeEmail); let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) + (db as any).select().from(users).where(eq((users as any).email, accountEmail)) ); const adminFullName = data.lastName && data.lastName.trim() @@ -1438,7 +1442,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) const userId = generateId(); user = { id: userId, - email: attendeeEmail, + email: accountEmail, password: null, name: adminFullName, phone: data.phone || null, @@ -1585,16 +1589,17 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z ? `${firstName} ${data.lastName.trim()}` : firstName; - // Find or create user + // Find or create user (see the note on `accountEmail` in the booking route) + const accountEmail = normalizeEmail(attendeeEmail); let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) + (db as any).select().from(users).where(eq((users as any).email, accountEmail)) ); if (!user) { const userId = generateId(); user = { id: userId, - email: attendeeEmail, + email: accountEmail, password: null, name: fullName, phone: data.phone || null, diff --git a/frontend/src/components/GoogleSignInButton.tsx b/frontend/src/components/GoogleSignInButton.tsx index 9aeabc3..2ca36b9 100644 --- a/frontend/src/components/GoogleSignInButton.tsx +++ b/frontend/src/components/GoogleSignInButton.tsx @@ -89,8 +89,24 @@ export default function GoogleSignInButton({ // to avoid open redirects. redirectAfterAuth(safeInternalPath(redirectTo, '/dashboard')); } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Google login failed'; - const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage; + // better-auth returns OAUTH_LINK_ERROR (message: "account not linked") + // when it refuses to attach the Google identity to the existing user + // row for that address. The backend now links unverified local rows + // (see lib/betterAuth.ts accountLinking), so this should be + // unreachable — but a bare "account not linked" toast is a dead end, + // so keep an actionable fallback rather than a generic one. + const isLinkError = (error as { code?: string } | null)?.code === 'OAUTH_LINK_ERROR'; + const errorMessage = isLinkError + ? 'This email is already registered. Sign in with your password, or use the "Email Link" option on the login page.' + : error instanceof Error + ? error.message + : 'Google login failed'; + const displayError = + locale === 'es' + ? isLinkError + ? 'Este correo ya esta registrado. Inicia sesion con tu contrasena o usa la opcion "Enlace por correo".' + : 'Error al iniciar sesion con Google' + : errorMessage; onError?.(displayError); toast.error(displayError); } finally { diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 602c5c6..150230a 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -114,7 +114,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { idToken: { token: credential }, }); if (error) { - throw new Error(messageFrom(error, 'Google login failed')); + // Carry the code through: GoogleSignInButton turns OAUTH_LINK_ERROR into + // something actionable instead of showing better-auth's bare + // "account not linked". + const err = new Error(messageFrom(error, 'Google login failed')); + (err as Error & { code?: string }).code = error.code; + throw err; } await refreshUser(); };