React SPA cutover: replace EJS views with /api/v1 + React 19 client#319
Conversation
Phase 1 of manager UI rewrite. Creates create-a-container/client/ with: - Vite 6 + React 19 + TypeScript - Tailwind CSS 4 via @tailwindcss/vite - @mieweb/ui 0.6.1 with BlueHive brand CSS - React Router 7 (data routers) with full route tree placeholder - TanStack Query 5, react-hook-form + zod, lucide-react Adds client:* scripts to create-a-container/package.json.
- New middlewares/api.js: apiAuth (session OR Bearer API key),
apiAdmin, asyncHandler, ApiError, jsonErrorHandler, csrfGuard.
- CSRF: csrf-csrf double-submit, exempts Bearer requests.
- Routers under /api/v1: auth (login w/ 2FA push, logout, register,
password reset), sites, sites/:id/containers (CRUD + metadata),
sites/:id/nodes (CRUD + Proxmox import + storages),
external-domains, groups, users (+invite), apikeys, settings, jobs
(incl. SSE stream).
- openapi.v1.yaml exposed via /api/v1/openapi.{json,yaml}.
- Mounted in server.js before legacy EJS routes.
- Legacy /apikeys, /sites, /jobs etc. remain functional.
- lib/api.ts: typed JSON client with credential cookies, CSRF
double-submit (lazy fetch + retry on 403), { data }/{ error }
envelope, 401 hook for redirect-to-login.
- lib/auth.ts: useSession query + login mutation w/ 2FA challenge
support + logout (clears CSRF, resets query cache).
- providers.tsx: ThemeProvider, ToastProvider, SidebarProvider,
CommandPaletteProvider.
- AppLayout: @mieweb/ui Sidebar + AppHeader + CommandPalette shell.
- AuthLayout: branded auth shell.
- RequireAuth: route guard, redirects to /login w/ redirect param.
- Sidebar/Header components composed from @mieweb/ui primitives.
- Auth pages: LoginPage (w/ 2FA push polling),
RegisterPage (supports invite tokens), RegisterSuccessPage
(w/ 2FA QR code), ResetPasswordRequestPage, ResetPasswordPage.
- Remove all legacy EJS routers and views (login, register, verify, users, groups, sites, external-domains, jobs, settings, apikeys, reset-password, nodes, containers) - Extract nginx-conf and dnsmasq template endpoints to routers/templates.js - Mount client/dist as static + SPA fallback for non-API, non-template routes - Drop connect-flash, method-override deps (no longer needed) - Keep views/nginx-conf.ejs and views/dnsmasq/* (server-side configuration templates)
- middlewares/api.js: drop __Host- prefix off-prod (requires Secure) and only bind CSRF token to session id once a user is signed in; saveUninitialized: false handed out a fresh session id per anon request, breaking double-submit. - client/vite.config.ts: drop /login, /logout, /nginx-conf, /dnsmasq proxies now that those are SPA routes; /api is the only backend proxy. - migrations: make 3 postgres-first migrations sqlite-compatible (guard undefined fk.constraintName, tolerate missing named constraints, rewrite UPDATE...FROM as scalar subquery, tolerate re-added columns). - package.json: add sqlite3 as a real dependency.
…bile - AppLayout: switch to h-screen overflow-hidden shell with internal main scroll so the sidebar can't scroll out of view on long pages. - Sidebar: pin SidebarHeader to h-16 (was 65px from py-4) so its bottom border matches the AppHeader bottom border to the pixel; rebuild the footer as a single user card (avatar + name + role + icon sign-out) with a top border so it visually anchors the bottom of the sidebar. - Header: drop the duplicate Container Manager brand on desktop (the sidebar already shows it); render a plain span only on mobile where the sidebar is collapsed off-canvas (AppHeaderBrand is hidden below md by @mieweb/ui so it can't be used there).
- Redesign AuthLayout with two-panel marketing/form layout and mobile header - Add FormPageHeader and FormPageLayout shared components for create/edit pages - Add useDocumentTitle hook and apply consistent titles across pages - Make list/table action rows wrap on narrow viewports - Tighten Sidebar: remove redundant Containers entry, use Button for logout - Scope Vite dev proxy to /api/* so client routes (e.g. /apikeys) aren't proxied - Stash legacy EJS screenshots under .attic/ for reference
# Conflicts: # create-a-container/routers/containers.js # create-a-container/routers/external-domains.js # create-a-container/routers/groups.js # create-a-container/routers/login.js # create-a-container/routers/nodes.js # create-a-container/routers/register.js # create-a-container/routers/sites.js # create-a-container/routers/users.js # create-a-container/views/layouts/header.ejs # create-a-container/views/login.ejs # create-a-container/views/users/index.ejs
- POST /api/v1/auth/dev: one-click dev login (admin/user) when NODE_ENV != production - GET /api/v1/health: now returns isDev flag - GET /api/v1/session: admins also receive pushNotificationUrl - POST /api/v1/users/email-all: broadcast email via existing sendBulkEmail util - LoginPage: dev-mode login buttons gated on /health.isDev - UsersListPage: 'Email all' action + modal (subject + message) - Sidebar: external 'MFA Admin' link rendered for admins when push URL configured
|
Merged latest Ported in
Already covered, no port needed:
Typecheck + Vite build are clean. `git diff origin/main..HEAD` now shows only the expected EJS deletes plus the SPA + JSON API surface. |
There was a problem hiding this comment.
Pull request overview
This PR completes the cutover of create-a-container from server-rendered EJS pages to a React SPA, backed by a new versioned JSON API under /api/v1, while preserving a small EJS-rendered “templates” surface for nginx/dnsmasq config generation.
Changes:
- Replaces legacy page routers/views with
/api/v1/*JSON routers (session + API-key auth, CSRF, consistent JSON envelopes, and error handling). - Adds a React 19 + Vite + Tailwind SPA client and serves the compiled app from Express for non-API routes.
- Updates migrations and supporting server wiring to match the new SPA/API architecture.
Reviewed changes
Copilot reviewed 109 out of 116 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| create-a-container/views/users/invite.ejs | Removed legacy EJS “invite user” view. |
| create-a-container/views/users/index.ejs | Removed legacy EJS users list page. |
| create-a-container/views/users/form.ejs | Removed legacy EJS users create/edit form. |
| create-a-container/views/sites/index.ejs | Removed legacy EJS sites list page. |
| create-a-container/views/sites/form.ejs | Removed legacy EJS sites create/edit form. |
| create-a-container/views/reset-password/reset.ejs | Removed legacy EJS reset-password “set new password” view. |
| create-a-container/views/reset-password/request.ejs | Removed legacy EJS reset-password request view. |
| create-a-container/views/register.ejs | Removed legacy EJS registration view. |
| create-a-container/views/register-success.ejs | Removed legacy EJS registration success/QR view. |
| create-a-container/views/nodes/index.ejs | Removed legacy EJS nodes list view. |
| create-a-container/views/nodes/import.ejs | Removed legacy EJS nodes import view. |
| create-a-container/views/login.ejs | Removed legacy EJS login view. |
| create-a-container/views/layouts/footer.ejs | Removed legacy EJS footer partial (version/issue link + bootstrap). |
| create-a-container/views/groups/index.ejs | Removed legacy EJS groups list view. |
| create-a-container/views/groups/form.ejs | Removed legacy EJS groups create/edit form. |
| create-a-container/views/external-domains/index.ejs | Removed legacy EJS external domains list view. |
| create-a-container/views/apikeys/show.ejs | Removed legacy EJS API key detail view. |
| create-a-container/views/apikeys/index.ejs | Removed legacy EJS API keys list view. |
| create-a-container/views/apikeys/form.ejs | Removed legacy EJS API key create form. |
| create-a-container/views/apikeys/created.ejs | Removed legacy EJS “API key created” one-time display view. |
| create-a-container/server.js | Switches to mounting /api/v1, templates router, and SPA static serving. |
| create-a-container/routers/verify.js | Removed legacy nginx auth_request verification route. |
| create-a-container/routers/templates.js | Adds EJS template endpoints for nginx/dnsmasq file generation. |
| create-a-container/routers/settings.js | Removed legacy settings page router (replaced by API). |
| create-a-container/routers/reset-password.js | Removed legacy reset-password router (replaced by API). |
| create-a-container/routers/register.js | Removed legacy register router (replaced by API). |
| create-a-container/routers/login.js | Removed legacy login router (replaced by API). |
| create-a-container/routers/groups.js | Removed legacy groups CRUD router (replaced by API). |
| create-a-container/routers/external-domains.js | Removed legacy external domains CRUD router (replaced by API). |
| create-a-container/routers/apikeys.js | Removed legacy API keys router (replaced by API). |
| create-a-container/routers/api/v1/index.js | Adds /api/v1 mount with CSRF token endpoint, OpenAPI endpoints, session endpoint, and sub-routers. |
| create-a-container/routers/api/v1/sites.js | Adds /api/v1/sites CRUD + nested mounts for site-scoped resources. |
| create-a-container/routers/api/v1/settings.js | Adds admin-only /api/v1/settings read/update endpoints. |
| create-a-container/routers/api/v1/jobs.js | Adds /api/v1/jobs read + status pagination + SSE stream endpoints. |
| create-a-container/routers/api/v1/groups.js | Adds admin-only /api/v1/groups CRUD endpoints. |
| create-a-container/routers/api/v1/external-domains.js | Adds admin-only /api/v1/external-domains CRUD; keeps Cloudflare key write-only. |
| create-a-container/routers/api/v1/apikeys.js | Adds per-user /api/v1/apikeys CRUD with one-time plaintext key return on create. |
| create-a-container/middlewares/api.js | Introduces API auth (session + bearer key), CSRF guard, JSON envelopes, and error handler. |
| create-a-container/package.json | Adds client helper scripts and new server dependencies for SPA/API support. |
| create-a-container/migrations/20260218000001-container-site-scoped-constraints.js | Makes migration more idempotent and adjusts backfill SQL. |
| create-a-container/migrations/20260218000000-remove-node-name-unique.js | Makes constraint removal tolerant of DB differences/idempotent. |
| create-a-container/migrations/20260217000000-make-external-domain-site-id-nullable.js | Safeguards FK constraint removal when constraint name is missing. |
| create-a-container/client/vite.config.ts | Adds Vite config with /api-scoped dev proxy and build output to dist/. |
| create-a-container/client/tsconfig.node.json | Adds TS config for Vite config/type-checking. |
| create-a-container/client/tsconfig.json | Adds TS project references. |
| create-a-container/client/tsconfig.app.json | Adds strict TS config for the SPA app sources. |
| create-a-container/client/src/vite-env.d.ts | Adds Vite client types reference. |
| create-a-container/client/src/styles/index.css | Adds Tailwind + @mieweb/ui brand styles entry. |
| create-a-container/client/src/pages/users/UsersListPage.tsx | Adds SPA users list UI (actions, delete, email-all modal integration). |
| create-a-container/client/src/pages/users/InviteUserPage.tsx | Adds SPA invite-user form page. |
| create-a-container/client/src/pages/users/EmailAllModal.tsx | Adds “email all users” modal + API integration. |
| create-a-container/client/src/pages/sites/SitesListPage.tsx | Adds SPA sites list UI with admin-only creation/deletion actions. |
| create-a-container/client/src/pages/sites/SiteFormPage.tsx | Adds SPA site create/edit form. |
| create-a-container/client/src/pages/settings/SettingsPage.tsx | Adds SPA settings page with env var field arrays and admin-only settings edits. |
| create-a-container/client/src/pages/NotFoundPage.tsx | Adds SPA 404 page. |
| create-a-container/client/src/pages/nodes/NodesListPage.tsx | Adds SPA nodes list UI for a site. |
| create-a-container/client/src/pages/nodes/NodeImportPage.tsx | Adds SPA Proxmox node import form. |
| create-a-container/client/src/pages/jobs/JobDetailPage.tsx | Adds SPA job detail + log streaming UI via SSE. |
| create-a-container/client/src/pages/groups/GroupsListPage.tsx | Adds SPA groups list UI. |
| create-a-container/client/src/pages/groups/GroupFormPage.tsx | Adds SPA group create/edit form. |
| create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx | Adds SPA external domains list UI. |
| create-a-container/client/src/pages/containers/ContainersListPage.tsx | Adds SPA containers list UI (site-scoped) with status badges and links. |
| create-a-container/client/src/pages/auth/ResetPasswordRequestPage.tsx | Adds SPA reset-password request page. |
| create-a-container/client/src/pages/auth/ResetPasswordPage.tsx | Adds SPA reset-password form with token validation. |
| create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx | Adds SPA registration success page with optional 2FA enrollment QR. |
| create-a-container/client/src/pages/apikeys/ApiKeysListPage.tsx | Adds SPA API keys list + one-time key display + revoke UI. |
| create-a-container/client/src/main.tsx | Adds SPA entrypoint with TanStack Query client setup and router mounting. |
| create-a-container/client/src/lib/useDocumentTitle.ts | Adds a small hook to manage page titles. |
| create-a-container/client/src/lib/types.ts | Adds typed models matching /api/v1 serializers. |
| create-a-container/client/src/lib/toast.ts | Adds centralized API error → toast helper. |
| create-a-container/client/src/lib/queries.ts | Adds shared TanStack Query keys and fetchers for /api/v1 resources. |
| create-a-container/client/src/lib/auth.ts | Adds session + login/logout + dev-login + 2FA challenge support helpers. |
| create-a-container/client/src/lib/api.ts | Adds typed fetch wrapper (JSON envelopes, CSRF token handling, 401 handling). |
| create-a-container/client/src/components/FormPageLayout.tsx | Adds shared create/edit form scaffold layout component. |
| create-a-container/client/src/components/FormPageHeader.tsx | Adds shared form header component. |
| create-a-container/client/src/app/Sidebar.tsx | Adds SPA sidebar navigation + logout action. |
| create-a-container/client/src/app/router.tsx | Adds SPA route map (auth + protected app routes). |
| create-a-container/client/src/app/RequireAuth.tsx | Adds authenticated-route guard around app routes. |
| create-a-container/client/src/app/providers.tsx | Adds provider composition for theme/toast/sidebar/command palette. |
| create-a-container/client/src/app/PlaceholderPage.tsx | Adds a placeholder page component (currently unused). |
| create-a-container/client/src/app/Header.tsx | Adds top header with search/theme toggle/user menu. |
| create-a-container/client/src/app/AuthLayout.tsx | Adds marketing-style two-panel auth layout with mobile fallback. |
| create-a-container/client/src/app/AppLayout.tsx | Adds main app layout shell (sidebar + header + outlet). |
| create-a-container/client/README.md | Adds SPA client dev/build instructions. |
| create-a-container/client/package.json | Adds client dependencies/scripts for React/Vite/Tailwind app. |
| create-a-container/client/index.html | Adds Vite HTML entrypoint. |
| create-a-container/client/.gitignore | Adds client-local ignore rules (dist, node_modules, etc.). |
| .gitignore | Ignores .tmp-verify/. |
Files not reviewed (2)
- create-a-container/client/package-lock.json: Language not supported
- create-a-container/package-lock.json: Language not supported
- UsersListPage: render EmailAllModal once at page root (not nested per row) - Sidebar: import ReactNode type instead of using React.ReactNode namespace - templates.js: return 404 when site not found in nginx route - server.js: load openapi.v1.yaml for /api Swagger UI - client/README.md: correct dev proxy description (only /api/*) - Remove unreferenced PlaceholderPage.tsx - Remove unused useApiErrorToast helper (lib/toast.ts) - middlewares/api.js: throw on missing CSRF secret in production
runleveldev
left a comment
There was a problem hiding this comment.
Theres more but Im hitting limits of GH API. This PR is so big I had to review from the CLI
- middlewares/api.js: load CSRF secret from SessionSecret table (not env); only bypass CSRF when a valid Bearer header is present AND there is no session cookie, so an attacker cannot bypass CSRF with a bogus Bearer. - server.js: initialize CSRF secret from DB at boot; mount /verify router; exclude /verify from SPA fallback. - routers/verify.js: nginx auth_request endpoint, supports session and Bearer auth and sets X-User-* identity headers. - routers/api/v1/auth.js: only define /auth/dev when NODE_ENV != production. - routers/templates.js: render an empty nginx config when no site exists yet so the manager API remains reachable for bootstrapping. - routers/api/v1/containers.js: include node.apiUrl in serialized container responses so the UI can link to Proxmox. - client/lib/types.ts: align ContainerMetadata with API (ports/httpServices/ env object/entrypoint string); add nodeApiUrl on Container. - client/lib/auth.ts: refetch session after login (and dev login) so guarded routes see the populated session before navigation. - client/pages/auth/LoginPage.tsx: also refetch session on 2FA approval. - client/pages/containers/ContainerFormPage.tsx: use mieweb-provided ghcr.io templates; parse HTTP service labels and ExposedPorts properly; merge the Custom template + lookup-metadata fields into one item; disable existing service fields except Require auth; pass dnsWarnings via location state when redirecting to the list. - client/pages/containers/ContainersListPage.tsx: restore VSCode and SSH links; link node name to Proxmox UI; display dnsWarnings from state. - client/pages/apikeys/ApiKeysListPage.tsx: dark-mode-readable code block. - client/pages/nodes/NodeImportPage.tsx: call /import (not /import-proxmox). - client/pages/sites/SiteFormPage.tsx: render DHCP range and DNS forwarders with commas (dnsmasq format). - client/app/Sidebar.tsx: site-scoped Containers (all users) and Nodes (admin) links when inside a site. - package.json / client/package.json / Makefile: 'dev' runs server + vite build --watch; Makefile installs and builds the client. - openapi.yaml: removed legacy spec; openapi.v1.yaml is the source of truth.
|
@wreiske Doug didn't think you'd have the cycles to finish this PR so I was fixing it up in a local branch (that was the random letters I was commenting, references to local commits I had). If you want to take the ball on seeing this through I'll let you but let me know soon which way you'd rather lean so I know I need to start implementing the fixes instead. |
…) for http services
801392e to
a051125
Compare

Summary
This branch cuts the
create-a-containerapp over from server-rendered EJS views to a React 19 SPA backed by a new versioned JSON API at/api/v1. Every legacy resource page has a React equivalent and the EJS view layer has been removed.Highlights
Backend —
/api/v1JSON APIcreate-a-container/routers/api/v1/for auth, users, groups, sites, nodes, containers, external-domains, jobs, settings, and api-keysmiddlewares/api.jsopenapi.v1.yamlFrontend — React 19 + Vite + Tailwind 4 SPA
@mieweb/ui, React Router 7, TanStack Query, react-hook-form + zodFormPageHeader/FormPageLayoutcomponents anduseDocumentTitlehook for consistent create/edit pages/api/*so client routes like/apikeysaren't interceptedOther
.attic/for referenceVerification
npm run type-check(client) — cleannpm run build(client) — succeedsnode --checkacrossserver.js,routers/api/v1/*.js, andmiddlewares/api.js— cleanCommits
feat(client): scaffold React 19 + Tailwind 4 + @mieweb/ui SPAfeat(api): add /api/v1 JSON API + CSRF + OpenAPI specfeat(client): SPA shell + auth flows on /api/v1feat(client): Phase 4 — feature pages for all resourcesfeat: cutover from EJS to React SPAfix(dev): SPA boots end-to-end on sqlitefix(client): align top bar with sidebar header, polish footer, fix mobilefeat(client): polish auth layout, form scaffolding, and responsive UI