Files
xeewy.be/frontend/src/contexts/AuthContext.tsx
Van Leemput Dayron 46e6edcd9c feat: add profile setup, verification, and authentication context
- Implement ProfileSetup component for user profile creation with username validation.
- Create ProtectedRoute component to guard routes based on authentication status.
- Add VerifyAuthCode component for email verification with resend functionality.
- Establish AuthContext for managing user authentication state and actions.
- Enhance CookieContext for consent management with server-side verification.
- Set up Firebase configuration for authentication and analytics.
- Update styles for authentication components and header layout.
- Configure Vite proxy for API requests during development.
2026-02-24 08:25:37 +01:00

349 lines
11 KiB
TypeScript

import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import {
GoogleAuthProvider,
createUserWithEmailAndPassword,
onAuthStateChanged,
signInWithEmailAndPassword,
signInWithPopup,
signInWithRedirect,
signOut,
type UserCredential,
type User
} from 'firebase/auth';
import { auth } from '../lib/firebase';
interface AuthContextType {
user: User | null;
loading: boolean;
codeVerified: boolean;
verificationLoading: boolean;
hasProfile: boolean;
profileLoading: boolean;
loginWithEmail: (email: string, password: string) => Promise<void>;
registerWithEmail: (email: string, password: string) => Promise<UserCredential>;
checkUsernameAvailability: (username: string) => Promise<boolean>;
registerProfile: (params: { email: string; firebaseUid: string; username: string }) => Promise<void>;
refreshProfileStatus: () => Promise<void>;
loginWithGoogle: () => Promise<void>;
sendLoginCode: () => Promise<void>;
verifyLoginCode: (code: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const provider = new GoogleAuthProvider();
provider.setCustomParameters({ prompt: 'select_account' });
const VERIFICATION_STORAGE_KEY = 'auth_code_session_v1';
const ABSOLUTE_MAX_SESSION_MS = 24 * 60 * 60 * 1000;
const PASSWORD_PROVIDER_ID = 'password';
const GOOGLE_PROVIDER_ID = 'google.com';
const readStoredSession = (): { email: string; token: string; expiresAt?: number; createdAt?: number } | null => {
const rawValue = localStorage.getItem(VERIFICATION_STORAGE_KEY);
if (!rawValue) return null;
try {
const parsed = JSON.parse(rawValue) as { email?: string; token?: string; expiresAt?: number; createdAt?: number };
if (!parsed.email || !parsed.token) return null;
return { email: parsed.email, token: parsed.token, expiresAt: parsed.expiresAt, createdAt: parsed.createdAt };
} catch {
return null;
}
};
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [codeVerified, setCodeVerified] = useState(false);
const [verificationLoading, setVerificationLoading] = useState(true);
const [hasProfile, setHasProfile] = useState(false);
const [profileLoading, setProfileLoading] = useState(true);
const isGoogleUser = (nextUser: User | null) => nextUser?.providerData.some((entry) => entry.providerId === GOOGLE_PROVIDER_ID) ?? false;
const usesPasswordAuth = (nextUser: User | null) =>
nextUser?.providerData.some((entry) => entry.providerId === PASSWORD_PROVIDER_ID) ?? false;
const revokeServerSession = async (email: string, token: string) => {
await fetch('/api/auth/logout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, token })
}).catch(() => null);
};
const hardLogout = async () => {
const storedSession = readStoredSession();
const email = user?.email || storedSession?.email;
if (email && storedSession?.token) {
await revokeServerSession(email, storedSession.token);
}
await signOut(auth);
localStorage.removeItem(VERIFICATION_STORAGE_KEY);
setCodeVerified(false);
setVerificationLoading(false);
setHasProfile(false);
setProfileLoading(false);
};
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (nextUser) => {
setUser(nextUser);
setLoading(false);
});
return unsubscribe;
}, []);
useEffect(() => {
const validateStoredSession = async () => {
if (!user?.email) {
localStorage.removeItem(VERIFICATION_STORAGE_KEY);
setCodeVerified(false);
setVerificationLoading(false);
return;
}
// Google auth skips the email code challenge.
if (isGoogleUser(user) && !usesPasswordAuth(user)) {
localStorage.removeItem(VERIFICATION_STORAGE_KEY);
setCodeVerified(true);
setVerificationLoading(false);
return;
}
const storedSession = readStoredSession();
if (!storedSession || storedSession.email !== user.email.toLowerCase()) {
setCodeVerified(false);
setVerificationLoading(false);
return;
}
const expiresAt = Number(storedSession.expiresAt || 0);
const createdAt = Number(storedSession.createdAt || 0);
const now = Date.now();
const absoluteExpiry = createdAt > 0 ? createdAt + ABSOLUTE_MAX_SESSION_MS : 0;
const effectiveExpiry = expiresAt > 0 && absoluteExpiry > 0 ? Math.min(expiresAt, absoluteExpiry) : 0;
if (effectiveExpiry > 0 && now >= effectiveExpiry) {
await hardLogout();
return;
}
try {
const response = await fetch('/api/auth/check-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: user.email, token: storedSession.token })
});
if (!response.ok) {
localStorage.removeItem(VERIFICATION_STORAGE_KEY);
await hardLogout();
return;
}
setCodeVerified(true);
} catch {
setCodeVerified(false);
} finally {
setVerificationLoading(false);
}
};
setVerificationLoading(true);
void validateStoredSession();
}, [user]);
useEffect(() => {
if (!user?.email) return;
const interval = window.setInterval(() => {
const storedSession = readStoredSession();
if (!storedSession?.token || !storedSession.email || storedSession.email !== user.email?.toLowerCase()) {
return;
}
const now = Date.now();
const expiresAt = Number(storedSession.expiresAt || 0);
const createdAt = Number(storedSession.createdAt || 0);
const absoluteExpiry = createdAt > 0 ? createdAt + ABSOLUTE_MAX_SESSION_MS : 0;
const effectiveExpiry = expiresAt > 0 && absoluteExpiry > 0 ? Math.min(expiresAt, absoluteExpiry) : 0;
if (effectiveExpiry > 0 && now >= effectiveExpiry) {
void hardLogout();
}
}, 30 * 1000);
return () => window.clearInterval(interval);
}, [user]);
const refreshProfileStatus = async () => {
if (isGoogleUser(user) && !usesPasswordAuth(user)) {
// Google-only accounts bypass the email-code/profile lookup on auth DB.
setHasProfile(true);
setProfileLoading(false);
return;
}
if (!user?.email || !codeVerified) {
setHasProfile(false);
setProfileLoading(false);
return;
}
try {
setProfileLoading(true);
const response = await fetch(`/api/auth/profile-status?email=${encodeURIComponent(user.email)}`);
const payload = (await response.json().catch(() => null)) as { hasProfile?: boolean } | null;
if (!response.ok) {
setHasProfile(false);
return;
}
setHasProfile(payload?.hasProfile === true);
} catch {
setHasProfile(false);
} finally {
setProfileLoading(false);
}
};
useEffect(() => {
void refreshProfileStatus();
}, [user, codeVerified]);
const loginWithGoogle = async () => {
try {
await signInWithPopup(auth, provider);
} catch (caught) {
const errorCode =
typeof caught === 'object' && caught !== null && 'code' in caught ? String((caught as { code?: string }).code) : '';
// Fallback for browsers that block popups.
if (errorCode === 'auth/popup-blocked') {
await signInWithRedirect(auth, provider);
return;
}
throw caught;
}
};
const loginWithEmail = async (email: string, password: string) => {
await signInWithEmailAndPassword(auth, email, password);
};
const registerWithEmail = async (email: string, password: string) => {
return createUserWithEmailAndPassword(auth, email, password);
};
const checkUsernameAvailability = async (username: string) => {
const response = await fetch(`/api/auth/username-available?username=${encodeURIComponent(username)}`);
const payload = (await response.json().catch(() => null)) as { available?: boolean; message?: string } | null;
if (!response.ok) {
throw new Error(payload?.message || 'Unable to check username.');
}
return payload?.available === true;
};
const registerProfile = async (params: { email: string; firebaseUid: string; username: string }) => {
const response = await fetch('/api/auth/register-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(payload?.message || 'Unable to register profile.');
}
setHasProfile(true);
};
const sendLoginCode = async () => {
if (!user?.email) {
throw new Error('No authenticated user.');
}
if (isGoogleUser(user) && !usesPasswordAuth(user)) {
throw new Error('Email code is only required for email/password accounts.');
}
const response = await fetch('/api/auth/send-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: user.email })
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(payload?.message || 'Unable to send code.');
}
};
const verifyLoginCode = async (code: string) => {
if (!user?.email) {
throw new Error('No authenticated user.');
}
if (isGoogleUser(user) && !usesPasswordAuth(user)) {
throw new Error('Email code is not required for Google accounts.');
}
const response = await fetch('/api/auth/verify-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: user.email, code })
});
const payload = (await response.json().catch(() => null)) as { token?: string; message?: string; expiresAt?: number } | null;
if (!response.ok || !payload?.token) {
throw new Error(payload?.message || 'Unable to verify code.');
}
localStorage.setItem(
VERIFICATION_STORAGE_KEY,
JSON.stringify({
email: user.email.toLowerCase(),
token: payload.token,
expiresAt: payload.expiresAt,
createdAt: Date.now()
})
);
setCodeVerified(true);
};
const logout = async () => {
await hardLogout();
};
const value = useMemo(
() => ({
user,
loading,
codeVerified,
verificationLoading,
hasProfile,
profileLoading,
loginWithEmail,
registerWithEmail,
checkUsernameAvailability,
registerProfile,
refreshProfileStatus,
loginWithGoogle,
sendLoginCode,
verifyLoginCode,
logout
}),
[codeVerified, hasProfile, loading, profileLoading, user, verificationLoading]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};