This commit is contained in:
seb
2026-06-23 11:04:28 +02:00
commit 24f68c94f3
16 changed files with 2580 additions and 0 deletions

74
src/auth/AuthContext.jsx Normal file
View File

@@ -0,0 +1,74 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useSnackbar } from 'notistack';
import { auth } from '../api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const { enqueueSnackbar } = useSnackbar();
const [user, setUser] = useState(null);
const [googleClientId, setGoogleClientId] = useState(null);
const [status, setStatus] = useState('loading'); // loading | authed | anon
useEffect(() => {
let active = true;
(async () => {
try {
const session = await auth.me();
if (!active) return;
setUser(session.user);
setGoogleClientId(session.googleClientId);
setStatus(session.user ? 'authed' : 'anon');
if (session.forbidden) {
enqueueSnackbar(session.message, { variant: 'warning' });
}
} catch (err) {
if (!active) return;
setStatus('anon');
enqueueSnackbar(`Session check failed: ${err.message}`, {
variant: 'error',
});
}
})();
return () => {
active = false;
};
}, [enqueueSnackbar]);
const loginWithGoogle = useCallback(
async (credential) => {
const { user: me } = await auth.loginWithGoogle(credential);
setUser(me);
setStatus('authed');
enqueueSnackbar(`Welcome, ${me.email}`, { variant: 'success' });
},
[enqueueSnackbar],
);
const logout = useCallback(async () => {
await auth.logout();
setUser(null);
setStatus('anon');
enqueueSnackbar('Signed out', { variant: 'info' });
}, [enqueueSnackbar]);
const value = useMemo(
() => ({ user, googleClientId, status, loginWithGoogle, logout }),
[user, googleClientId, status, loginWithGoogle, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
return ctx;
}

97
src/auth/googleSignIn.js Normal file
View File

@@ -0,0 +1,97 @@
const GIS_SCRIPT = 'https://accounts.google.com/gsi/client';
let scriptPromise;
function loadGoogleScript() {
if (window.google?.accounts?.id) return Promise.resolve();
if (scriptPromise) return scriptPromise;
scriptPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = GIS_SCRIPT;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Google Sign-In'));
document.head.appendChild(script);
});
return scriptPromise;
}
function clickRenderedGoogleButton(clientId, onCredential) {
const container = document.createElement('div');
container.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;pointer-events:none;';
document.body.appendChild(container);
window.google.accounts.id.renderButton(container, {
type: 'standard',
size: 'large',
theme: 'outline',
});
const button = container.querySelector('[role="button"]');
if (!button) {
container.remove();
throw new Error('Could not open Google Sign-In');
}
window.google.accounts.id.initialize({
client_id: clientId,
callback: (response) => {
container.remove();
onCredential(response.credential);
},
auto_select: false,
cancel_on_tap_outside: true,
});
button.click();
}
/**
* Prompt for a Google ID token (One Tap, falling back to the sign-in popup).
*/
export async function requestGoogleIdToken(clientId) {
if (!clientId) {
throw new Error('Google client ID is not configured');
}
await loadGoogleScript();
return new Promise((resolve, reject) => {
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
fn(value);
};
window.google.accounts.id.initialize({
client_id: clientId,
callback: (response) => finish(resolve, response.credential),
auto_select: false,
cancel_on_tap_outside: true,
});
window.google.accounts.id.prompt((notification) => {
if (settled) return;
const needsFallback =
notification.isNotDisplayed() ||
notification.isSkippedMoment() ||
notification.isDismissedMoment();
if (!needsFallback) return;
try {
clickRenderedGoogleButton(clientId, (credential) =>
finish(resolve, credential),
);
} catch (err) {
finish(reject, err);
}
});
});
}