Genesis
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
46
README.md
Normal file
46
README.md
Normal 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
12
index.html
Normal 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
2002
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
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>
|
||||
);
|
||||
}
|
||||
16
vite.config.js
Normal file
16
vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user