Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie sessions, validated against the database on every request so revocation, bans and role changes take effect immediately. Backend: - betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and the admin plugin; auth-schema.ts maps Better Auth's models onto the existing `users` table so user IDs and their foreign keys survive intact. - routes/auth.ts is gone; Better Auth serves the standard endpoints and authExt.ts carries the flows it doesn't cover. - auth.ts shrinks to session resolution and helpers; sessions/revocation in dashboard.ts now read and delete `auth_sessions` rows directly. - Schema adds the Better Auth core + admin columns (email_verified, image, banned, ban_reason, ban_expires), with migrations and tests. - rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES. - passwordPolicy.ts centralises password validation. - Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible with Better Auth. Frontend: - auth-client.ts plus a reworked AuthContext and api/client.ts move to cookie-based sessions; no more bearer tokens in requests or middleware. photo-api: - Validate Better Auth session cookies against the shared auth_sessions table instead of verifying JWTs; JWT_SECRET is no longer needed for user auth, and PHOTO_VIEW_SECRET now signs gallery view tokens. BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the deprecated JWT_SECRET stays only as the photo-api view-token fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
6.5 KiB
TypeScript
214 lines
6.5 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) {
|
|
throw new Error(messageFrom(error, 'Google login failed'));
|
|
}
|
|
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;
|
|
}
|