Files
Spanglish/frontend/src/context/AuthContext.tsx
T
MichilisandClaude Opus 5 87bf9a6151 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 <noreply@anthropic.com>
2026-08-23 05:31:02 +00:00

219 lines
6.7 KiB
TypeScript

'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { authClient } from '@/lib/auth-client';
import { fetchApi } from '@/lib/api/client';
interface User {
id: string;
email: string;
name: string;
role: string;
phone?: string;
languagePreference?: string;
isClaimed?: boolean;
rucNumber?: string;
accountStatus?: string;
}
interface AuthContextType {
user: User | null;
/** Always null: auth moved to httpOnly session cookies (Better Auth). Kept for compat. */
token: string | null;
isLoading: boolean;
isAdmin: boolean;
hasAdminAccess: boolean;
login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<User | null>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
updateUser: (user: User) => void;
setAuthData: (data: { user: User; token?: string }) => void;
refreshUser: () => Promise<User | null>;
}
interface RegisterData {
email: string;
password: string;
name: string;
phone?: string;
languagePreference?: string;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Legacy storage from the pre-Better-Auth JWT era; cleared once on mount.
const LEGACY_TOKEN_KEY = 'spanglish-token';
const LEGACY_USER_KEY = 'spanglish-user';
const LEGACY_AUTH_COOKIE = 'spanglish-auth';
function mapSessionUser(sessionUser: any): User {
return {
id: sessionUser.id,
email: sessionUser.email,
name: sessionUser.name,
role: sessionUser.role ?? 'user',
phone: sessionUser.phone ?? undefined,
languagePreference: sessionUser.languagePreference ?? undefined,
isClaimed: Boolean(sessionUser.isClaimed ?? true),
rucNumber: sessionUser.rucNumber ?? undefined,
accountStatus: sessionUser.accountStatus ?? 'active',
};
}
function messageFrom(error: { message?: string; code?: string; status?: number } | null, fallback: string): string {
return error?.message || fallback;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = useCallback(async (): Promise<User | null> => {
try {
const { data } = await authClient.getSession();
if (data?.user) {
const mapped = mapSessionUser(data.user);
setUser(mapped);
return mapped;
}
setUser(null);
return null;
} catch (error) {
// Network error: keep current state
console.error('Failed to refresh user data:', error);
return null;
}
}, []);
useEffect(() => {
// One-time cleanup of legacy JWT-era storage
try {
localStorage.removeItem(LEGACY_TOKEN_KEY);
localStorage.removeItem(LEGACY_USER_KEY);
document.cookie = `${LEGACY_AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
} catch {
/* SSR / storage unavailable */
}
refreshUser().finally(() => setIsLoading(false));
}, [refreshUser]);
const login = async (email: string, password: string) => {
const { error } = await authClient.signIn.email({ email, password });
if (error) {
throw new Error(messageFrom(error, 'Login failed'));
}
await refreshUser();
};
const loginWithGoogle = async (credential: string) => {
const { error } = await authClient.signIn.social({
provider: 'google',
idToken: { token: credential },
});
if (error) {
// 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();
};
const loginWithMagicLink = async (magicToken: string): Promise<User | null> => {
const { error } = await authClient.magicLink.verify({ query: { token: magicToken } });
if (error) {
throw new Error(messageFrom(error, 'Invalid or expired link'));
}
return refreshUser();
};
const register = async (registerData: RegisterData) => {
const { error } = await authClient.signUp.email({
email: registerData.email,
password: registerData.password,
name: registerData.name,
phone: registerData.phone || undefined,
languagePreference: registerData.languagePreference || undefined,
} as any);
if (error) {
// Existing-but-unclaimed accounts (created during guest booking) should
// point the user to the claim flow instead of a bare "already exists".
if (error.code === 'USER_ALREADY_EXISTS') {
try {
const { canClaim } = await fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(registerData.email)}`
);
if (canClaim) {
const err = new Error(
'This email has an unclaimed account from a previous booking. Use "Email Link" on the login page to claim it.'
);
(err as any).canClaim = true;
throw err;
}
} catch (e: any) {
if (e?.canClaim) throw e;
/* eligibility check failed: fall through to generic message */
}
}
throw new Error(messageFrom(error, 'Registration failed'));
}
await refreshUser();
};
const logout = useCallback(() => {
// Best-effort server-side revocation; local state clears regardless.
authClient.signOut().catch(() => {
/* ignore network errors */
});
setUser(null);
}, []);
const updateUser = useCallback((updatedUser: User) => {
setUser(updatedUser);
}, []);
// Compat shim for callers that used to push {user, token} after custom auth
// flows; the session cookie is already set by then, so only state updates.
const setAuthData = useCallback((data: { user: User; token?: string }) => {
setUser(data.user);
}, []);
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
const hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing';
return (
<AuthContext.Provider
value={{
user,
token: null,
isLoading,
isAdmin,
hasAdminAccess,
login,
loginWithGoogle,
loginWithMagicLink,
register,
logout,
updateUser,
setAuthData,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}