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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
dist-ssr
*.local
.DS_Store

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# Dashfront
Vite + React web client for Dashback. Auth uses Google Sign-In and a
cookie session on the Dashback API.
## Stack
- Vite 8 (`type: module`)
- React 19 (function components only)
- MUI 9 (`@mui/material`, `@mui/icons-material`, Emotion)
- notistack (toast notifications)
- Google Identity Services + Dashback cookie auth
## Getting started
```bash
npm install
npm run dev
```
Open `http://localhost:4209`. The dev server proxies `/api` to Dashback at
`http://localhost:4208`.
Dashback must be running and your Google account must be authorized on the
backend.
## Auth flow
1. On boot the app calls `GET /api/auth/me` with `credentials: 'include'`.
- **200** — `{ user, googleClientId }` (valid session)
- **401 / 403** — `{ error, googleClientId }` (no session or no longer authorized)
2. The login page uses `googleClientId` from that response to obtain a Google ID
token and posts it to `POST /api/auth/login` as `{ idToken }`.
3. Dashback sets an HTTP-only `token` cookie; all later API calls send it
automatically.
4. Sign out calls `POST /api/auth/logout`.
The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie
requirement without needing HTTPS on the frontend dev server.
## Architecture
- `src/api/index.js` — all backend communication (`credentials: 'include'`).
- `src/auth/googleSignIn.js` — Google Identity Services helper.
- `src/auth/AuthContext.jsx` — session state + login/logout.
- `src/components/*` — small function components with local state.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashfront</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2002
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "dashfront",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.2",
"notistack": "^3.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.2",
"vite": "^8.0.16"
}
}

27
src/App.jsx Normal file
View 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
View 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
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);
}
});
});
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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,
},
];

View File

@@ -0,0 +1,9 @@
import Typography from '@mui/material/Typography';
export default function DashboardPage() {
return (
<Typography variant="h5" sx={{ fontWeight: 700 }}>
Dashboard
</Typography>
);
}

16
vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 4209,
allowedHosts: true,
proxy: {
'/api': {
target: 'http://localhost:4208',
changeOrigin: true,
},
},
},
});