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>
219 lines
6.7 KiB
TypeScript
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;
|
|
}
|