- 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.
349 lines
11 KiB
TypeScript
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;
|
|
};
|