import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import pb from '../lib/pocketbase'; import { trackEvent } from '../lib/analytics'; import { resolveAuthError, type AuthErrorAction } from '../lib/auth-errors'; export interface AuthUser { id: string; email: string; isAdmin: boolean; subscription: string; newsletter: boolean; canSeeListings: boolean; } function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser { if (typeof record.email !== 'string') { throw new Error('PocketBase record missing email field'); } return { id: record.id, email: record.email, isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false, subscription: typeof record.subscription === 'string' ? record.subscription : 'free', newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false, canSeeListings: typeof record.can_see_listings === 'boolean' ? record.can_see_listings : false, }; } function authRefreshInvalidated(error: unknown): boolean { const status = (error as { status?: unknown })?.status; return status === 401 || status === 403; } /** * Normalize an email so login and registration are case- and * whitespace-insensitive: ` John@Example.com` and `john@example.com` * resolve to the same account. Applied at the PocketBase boundary rather * than on the input field, so the user still sees exactly what they typed. */ function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } export function useAuth() { const { t } = useTranslation(); const [user, setUser] = useState(() => { if (pb.authStore.isValid && pb.authStore.record) { return recordToUser(pb.authStore.record); } return null; }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [errorAction, setErrorAction] = useState(null); // Sync with authStore changes (cross-tab, external updates) useEffect(() => { const unsubscribe = pb.authStore.onChange(() => { if (pb.authStore.isValid && pb.authStore.record) { setUser(recordToUser(pb.authStore.record)); } else { setUser(null); } }); return unsubscribe; }, []); const login = useCallback( async (email: string, password: string) => { setLoading(true); setError(null); setErrorAction(null); try { const result = await pb .collection('users') .authWithPassword(normalizeEmail(email), password); setUser(recordToUser(result.record)); trackEvent('Login', { method: 'email' }); } catch (err) { const { message, action } = resolveAuthError(err, 'login', t); setError(message); setErrorAction(action); throw err; } finally { setLoading(false); } }, [t] ); const register = useCallback( async (email: string, password: string) => { setLoading(true); setError(null); setErrorAction(null); const normalizedEmail = normalizeEmail(email); try { // Step 1: create the account. A failure here means nothing was created, // so surface a friendly, field-aware message (e.g. "email already exists"). try { await pb.collection('users').create({ email: normalizedEmail, password, passwordConfirm: password, newsletter: true, }); } catch (err) { const { message, action } = resolveAuthError(err, 'register', t); setError(message); setErrorAction(action); throw err; } // Step 2: the account now EXISTS. If auto-login fails (commonly a 429 // rate limit from the two back-to-back writes) the account is NOT // orphaned: tell the user it was created and let them log in with the // same credentials, instead of a misleading "registration failed". try { const result = await pb.collection('users').authWithPassword(normalizedEmail, password); setUser(recordToUser(result.record)); trackEvent('Register'); } catch (err) { const status = (err as { status?: number } | null)?.status; setError( status === 429 ? t('auth.accountCreatedRateLimited') : t('auth.accountCreatedPleaseLogIn') ); setErrorAction('switchToLogin'); throw err; } } finally { setLoading(false); } }, [t] ); const loginWithOAuth = useCallback( async (provider: string) => { setLoading(true); setError(null); setErrorAction(null); try { const result = await pb.collection('users').authWithOAuth2({ provider, createData: { newsletter: true }, }); setUser(recordToUser(result.record)); trackEvent('Login', { method: provider }); } catch (err) { const { message, action } = resolveAuthError(err, 'oauth', t); setError(message); setErrorAction(action); throw err; } finally { setLoading(false); } }, [t] ); const logout = useCallback(() => { trackEvent('Logout'); pb.authStore.clear(); setUser(null); }, []); const requestPasswordReset = useCallback( async (email: string) => { setLoading(true); setError(null); setErrorAction(null); try { await pb.collection('users').requestPasswordReset(normalizeEmail(email)); } catch (err) { const { message, action } = resolveAuthError(err, 'reset', t); setError(message); setErrorAction(action); throw err; } finally { setLoading(false); } }, [t] ); const confirmPasswordReset = useCallback( async (token: string, password: string) => { setLoading(true); setError(null); setErrorAction(null); try { // PocketBase requires the password twice; the reset form uses a single // (show/hide) field, so both arguments are the same value. await pb.collection('users').confirmPasswordReset(token, password, password); } catch (err) { const { message, action } = resolveAuthError(err, 'confirmReset', t); setError(message); setErrorAction(action); throw err; } finally { setLoading(false); } }, [t] ); const refreshAuth = useCallback(async () => { setLoading(true); setError(null); try { const result = await pb.collection('users').authRefresh(); const refreshedUser = recordToUser(result.record); setUser(refreshedUser); return refreshedUser; } catch (err) { // Only clear auth on explicit token rejection. Network and 5xx failures // should not log users out during background refresh. if (authRefreshInvalidated(err)) { pb.authStore.clear(); setUser(null); } throw err; } finally { setLoading(false); } }, []); const clearError = useCallback(() => { setError(null); setErrorAction(null); }, []); return { user, loading, error, errorAction, login, register, loginWithOAuth, logout, requestPasswordReset, confirmPasswordReset, refreshAuth, clearError, }; }