215 lines
7.0 KiB
TypeScript
215 lines
7.0 KiB
TypeScript
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { getBackendUrl } from '@/lib/utils';
|
|
import GoogleAuthModal from '@/components/Auth/GoogleAuthModal';
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Types
|
|
// ──────────────────────────────────────────────────
|
|
|
|
export interface AuthUser {
|
|
id: number;
|
|
email: string;
|
|
name: string;
|
|
pictureUrl?: string;
|
|
verifiedStudent: boolean;
|
|
}
|
|
|
|
interface AuthContextType {
|
|
user: AuthUser | null;
|
|
isAuthenticated: boolean;
|
|
isVerifiedStudent: boolean;
|
|
isLoading: boolean;
|
|
isAuthModalOpen: boolean;
|
|
loginWithGoogle: () => void;
|
|
openAuthModal: () => void;
|
|
closeAuthModal: () => void;
|
|
logout: () => void;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Google Client ID
|
|
// ──────────────────────────────────────────────────
|
|
|
|
const GOOGLE_CLIENT_ID =
|
|
import.meta.env.VITE_GOOGLE_CLIENT_ID ||
|
|
'81935488244-vmab5t8fipfof1n6l7di7eg4fve6k8lf.apps.googleusercontent.com';
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Context
|
|
// ──────────────────────────────────────────────────
|
|
|
|
const AuthContext = createContext<AuthContextType>({
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isVerifiedStudent: false,
|
|
isLoading: true,
|
|
isAuthModalOpen: false,
|
|
loginWithGoogle: () => {},
|
|
openAuthModal: () => {},
|
|
closeAuthModal: () => {},
|
|
logout: () => {},
|
|
});
|
|
|
|
export const useAuth = () => useContext(AuthContext);
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Google GSI global type
|
|
// ──────────────────────────────────────────────────
|
|
|
|
declare global {
|
|
interface Window {
|
|
google?: {
|
|
accounts: {
|
|
id: {
|
|
initialize: (config: Record<string, unknown>) => void;
|
|
prompt: (callback?: (notification: { isNotDisplayed: () => boolean; isSkippedMoment: () => boolean }) => void) => void;
|
|
renderButton: (element: HTMLElement, config: Record<string, unknown>) => void;
|
|
revoke: (email: string, callback: () => void) => void;
|
|
};
|
|
};
|
|
};
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Provider
|
|
// ──────────────────────────────────────────────────
|
|
|
|
const STORAGE_KEY = 'rit_auth_user';
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
|
|
|
|
const openAuthModal = useCallback(() => setIsAuthModalOpen(true), []);
|
|
const closeAuthModal = useCallback(() => setIsAuthModalOpen(false), []);
|
|
|
|
// Handle the credential response from Google GSI
|
|
const handleCredentialResponse = useCallback(async (response: { credential: string }) => {
|
|
try {
|
|
const res = await fetch(getBackendUrl('/api/auth/google'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ credential: response.credential }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
console.error('Auth failed:', res.status);
|
|
return;
|
|
}
|
|
|
|
const userData: AuthUser = await res.json();
|
|
setUser(userData);
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(userData));
|
|
setIsAuthModalOpen(false);
|
|
} catch (err) {
|
|
console.error('Google auth error:', err);
|
|
}
|
|
}, []);
|
|
|
|
// Initialize Google GSI
|
|
useEffect(() => {
|
|
// 1. Restore session from localStorage
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
if (stored) {
|
|
try {
|
|
const parsed = JSON.parse(stored) as AuthUser;
|
|
setUser(parsed);
|
|
|
|
// Validate session is still valid against backend
|
|
fetch(getBackendUrl(`/api/auth/me?email=${encodeURIComponent(parsed.email)}`))
|
|
.then((res) => {
|
|
if (!res.ok) {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
setUser(null);
|
|
} else {
|
|
return res.json();
|
|
}
|
|
})
|
|
.then((freshData) => {
|
|
if (freshData) {
|
|
setUser(freshData);
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(freshData));
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Backend offline, keep cached session
|
|
});
|
|
} catch {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
}
|
|
}
|
|
setIsLoading(false);
|
|
|
|
// 2. Initialize Google GSI when the script loads
|
|
const initGSI = () => {
|
|
if (!window.google || !GOOGLE_CLIENT_ID) return;
|
|
|
|
window.google.accounts.id.initialize({
|
|
client_id: GOOGLE_CLIENT_ID,
|
|
callback: handleCredentialResponse,
|
|
auto_select: false,
|
|
cancel_on_tap_outside: true,
|
|
});
|
|
};
|
|
|
|
if (window.google) {
|
|
initGSI();
|
|
} else {
|
|
const interval = setInterval(() => {
|
|
if (window.google) {
|
|
clearInterval(interval);
|
|
initGSI();
|
|
}
|
|
}, 200);
|
|
setTimeout(() => clearInterval(interval), 10000);
|
|
}
|
|
}, [handleCredentialResponse]);
|
|
|
|
const loginWithGoogle = useCallback(() => {
|
|
// Open the clean Google Sign-In modal directly
|
|
setIsAuthModalOpen(true);
|
|
|
|
// Also trigger One Tap prompt as a secondary background hint if supported
|
|
if (window.google) {
|
|
try {
|
|
window.google.accounts.id.prompt();
|
|
} catch {
|
|
// Silently ignore if browser blocks One Tap
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const logout = useCallback(() => {
|
|
if (user?.email && window.google) {
|
|
try {
|
|
window.google.accounts.id.revoke(user.email, () => {});
|
|
} catch {}
|
|
}
|
|
setUser(null);
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
setIsAuthModalOpen(false);
|
|
}, [user]);
|
|
|
|
return (
|
|
<AuthContext.Provider
|
|
value={{
|
|
user,
|
|
isAuthenticated: !!user,
|
|
isVerifiedStudent: !!user?.verifiedStudent,
|
|
isLoading,
|
|
isAuthModalOpen,
|
|
loginWithGoogle,
|
|
openAuthModal,
|
|
closeAuthModal,
|
|
logout,
|
|
}}
|
|
>
|
|
{children}
|
|
<GoogleAuthModal isOpen={isAuthModalOpen} onClose={closeAuthModal} />
|
|
</AuthContext.Provider>
|
|
);
|
|
}
|