Genesis
This commit is contained in:
27
src/App.jsx
Normal file
27
src/App.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { useAuth } from './auth/AuthContext';
|
||||
import LoginPage from './components/LoginPage';
|
||||
import AppShell from './components/AppShell';
|
||||
|
||||
// Top-level router by auth status. Kept tiny so it rarely re-renders.
|
||||
export default function App() {
|
||||
const { status } = useAuth();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return status === 'authed' ? <AppShell /> : <LoginPage />;
|
||||
}
|
||||
69
src/api/index.js
Normal file
69
src/api/index.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// Single source of truth for all backend communication.
|
||||
|
||||
async function http(path, { method = 'GET', body } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch {
|
||||
message = (await res.text().catch(() => message)) || message;
|
||||
}
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const auth = {
|
||||
async me() {
|
||||
try {
|
||||
const data = await http('/api/auth/me');
|
||||
return {
|
||||
user: data.user ?? null,
|
||||
googleClientId: data.googleClientId ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
return {
|
||||
user: null,
|
||||
googleClientId: err.data?.googleClientId ?? null,
|
||||
forbidden: err.status === 403,
|
||||
message: err.message,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async loginWithGoogle(idToken) {
|
||||
const data = await http('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { idToken },
|
||||
});
|
||||
return { user: data.user };
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await http('/api/auth/logout', { method: 'POST' });
|
||||
} catch (err) {
|
||||
if (err.status !== 401) throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default { auth };
|
||||
74
src/auth/AuthContext.jsx
Normal file
74
src/auth/AuthContext.jsx
Normal 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
97
src/auth/googleSignIn.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
56
src/components/AppShell.jsx
Normal file
56
src/components/AppShell.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import UserMenu from './UserMenu';
|
||||
import { NAV_ITEMS } from '../nav';
|
||||
|
||||
const SIDEBAR_WIDTH = 240;
|
||||
|
||||
// App layout. State lives in the children (UserMenu, page content) so the
|
||||
// shell itself does not re-render when their state changes.
|
||||
export default function AppShell() {
|
||||
const [activeId, setActiveId] = useState(NAV_ITEMS[0].id);
|
||||
const activeItem = NAV_ITEMS.find((item) => item.id === activeId) ?? NAV_ITEMS[0];
|
||||
const ActivePage = activeItem.Page;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: 'grey.50' }}>
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{
|
||||
width: SIDEBAR_WIDTH,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<List disablePadding sx={{ flexGrow: 1 }}>
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
|
||||
<ListItemButton
|
||||
key={id}
|
||||
disableGutters
|
||||
selected={id === activeId}
|
||||
onClick={() => setActiveId(id)}
|
||||
sx={{ borderRadius: 0, px: 2 }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={label} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<UserMenu />
|
||||
</Box>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, py: 4, px: 3 }}>
|
||||
<ActivePage />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
54
src/components/LoginPage.jsx
Normal file
54
src/components/LoginPage.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { requestGoogleIdToken } from '../auth/googleSignIn';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { loginWithGoogle, googleClientId, status } = useAuth();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const idToken = await requestGoogleIdToken(googleClientId);
|
||||
await loginWithGoogle(idToken);
|
||||
} catch (err) {
|
||||
enqueueSnackbar(`Login failed: ${err.message}`, { variant: 'error' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loading = status === 'loading';
|
||||
const ready = Boolean(googleClientId);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'grey.100',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<GoogleIcon />}
|
||||
disabled={busy || loading || !ready}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
{busy ? 'Signing in…' : 'Sign in with Google'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
52
src/components/UserMenu.jsx
Normal file
52
src/components/UserMenu.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
// Menu open/close state is local; toggling it only re-renders this component.
|
||||
export default function UserMenu() {
|
||||
const { user, logout } = useAuth();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1.5, alignSelf: 'flex-start' }}>
|
||||
<IconButton
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
|
||||
>
|
||||
<Avatar alt={user?.email} sx={{ width: 32, height: 32 }}>
|
||||
{user?.email?.[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<MenuItem disabled>{user?.email}</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorEl(null);
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<LogoutIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Sign out</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/main.jsx
Normal file
25
src/main.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { AuthProvider } from './auth/AuthContext';
|
||||
import App from './App';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: { mode: 'light', primary: { main: '#3b5bdb' } },
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider
|
||||
maxSnack={3}
|
||||
autoHideDuration={2000}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
11
src/nav.js
Normal file
11
src/nav.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: DashboardIcon,
|
||||
Page: DashboardPage,
|
||||
},
|
||||
];
|
||||
9
src/pages/DashboardPage.jsx
Normal file
9
src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
Dashboard
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user