diff --git a/src/Root.tsx b/src/Root.tsx index d82bfda..c56ac05 100644 --- a/src/Root.tsx +++ b/src/Root.tsx @@ -20,6 +20,7 @@ import Banner from './components/utils/components/Banner'; import { ThemeProvider } from './contexts/ThemeContext'; import { AuthProvider } from './backend/authContext'; import UnifiedAvailabilityPage from './components/UnifiedAvailabilityPage/UnifiedAvailabilityPage'; +import GoogleOneTapPrompt from './components/utils/components/GoogleOneTap/GoogleOneTapPrompt'; import { useState } from 'react'; // legacy support for /groupview/:code @@ -36,6 +37,7 @@ function Root() {
{/* */} + } /> diff --git a/src/backend/authContext.tsx b/src/backend/authContext.tsx index ec4c9e6..6b891ad 100644 --- a/src/backend/authContext.tsx +++ b/src/backend/authContext.tsx @@ -10,6 +10,7 @@ import { signOut, GoogleAuthProvider, onAuthStateChanged, + signInWithCredential, User, } from 'firebase/auth'; import { auth, db } from './firebase'; @@ -21,6 +22,7 @@ interface AuthContextType { currentUser: User | null; loading: boolean; login: () => Promise; + loginWithIdToken: (idToken: string) => Promise; logout: () => Promise; } @@ -87,6 +89,19 @@ export const AuthProvider: React.FC = ({ children }) => { } }; + // Sign in with Google One Tap (ID token) via Firebase + const loginWithIdToken = async (idToken: string): Promise => { + try { + const credential = GoogleAuthProvider.credential(idToken); + const result = await signInWithCredential(auth, credential); + await ensureUserDocExists(result.user); + return result.user; + } catch (error: any) { + console.error('Error signing in with One Tap:', error); + throw error; + } + }; + // Sign out from Firebase const logout = async (): Promise => { try { @@ -102,6 +117,7 @@ export const AuthProvider: React.FC = ({ children }) => { currentUser, loading, login, + loginWithIdToken, logout, }; diff --git a/src/components/utils/components/GoogleOneTap/GoogleOneTapPrompt.tsx b/src/components/utils/components/GoogleOneTap/GoogleOneTapPrompt.tsx new file mode 100644 index 0000000..f678974 --- /dev/null +++ b/src/components/utils/components/GoogleOneTap/GoogleOneTapPrompt.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import { useAuth } from '../../../../backend/authContext'; + +const GSI_SRC = 'https://accounts.google.com/gsi/client'; + +function loadGsiScript(): Promise { + return new Promise((resolve, reject) => { + const googleAny = window.google as any; + if (googleAny?.accounts?.id) { + resolve(); + return; + } + + const existing = document.querySelector( + `script[src="${GSI_SRC}"]`, + ); + if (existing) { + existing.addEventListener('load', () => resolve()); + existing.addEventListener('error', () => reject(new Error('GSI failed'))); + return; + } + + const script = document.createElement('script'); + script.src = GSI_SRC; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('GSI failed')); + document.head.appendChild(script); + }); +} + +export default function GoogleOneTapPrompt() { + const { currentUser, loading, loginWithIdToken } = useAuth(); + + React.useEffect(() => { + if (loading) return; + if (currentUser) return; + + const clientId = process.env.REACT_APP_CLIENT_ID_GAPI; + if (!clientId) return; + + let cancelled = false; + + loadGsiScript() + .then(() => { + if (cancelled) return; + const gsi = (window.google as any)?.accounts?.id; + if (!gsi) return; + + gsi.initialize({ + client_id: clientId, + callback: async (response: { credential?: string }) => { + if (!response?.credential) return; + try { + await loginWithIdToken(response.credential); + } catch { + // If One Tap fails, the user can still use existing sign-in flows. + } + }, + auto_select: false, + cancel_on_tap_outside: true, + }); + + gsi.prompt(); + }) + .catch(() => { + // Script load failed; ignore silently. + }); + + return () => { + cancelled = true; + (window.google as any)?.accounts?.id?.cancel?.(); + }; + }, [currentUser, loading, loginWithIdToken]); + + return null; +} + diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 40e1f70..f5de38d 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -3,7 +3,8 @@ declare global { interface Window { - FB: typeof FB + FB: typeof FB; + google?: any; } }