React SPA cutover: replace EJS views with /api/v1 + React 19 client#319
React SPA cutover: replace EJS views with /api/v1 + React 19 client#319wreiske wants to merge 12 commits into
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
| as: 'externalDomains', | ||
| }], | ||
| }); | ||
| if (!site) return res.status(404).send('Site not found'); |
There was a problem hiding this comment.
This line breaks the bootstrapping assumptions. The nginx template is designed to have a "no site" fallback that allows the administrator to access the API to configure the first site, without that bootstrapping would require plaintext HTTP access, breaking security requirements for registering the first user
| ] | ||
| }, | ||
| "scripts": { | ||
| "dev": "nodemon server.js", |
There was a problem hiding this comment.
Running the server in dev should also run the client in dev, preferably just watching the directory and rebuilding the dist files as needed to prevent the extra http hop that proxying to the dev port from the api server would cause.
| @@ -0,0 +1,34 @@ | |||
| { | |||
There was a problem hiding this comment.
This package needs to be installed and built in the Makefile in the project root so that the Manager docker container image is setup correctly on first boot. Administrators should not be expected to need to build the client before the UI is available
|
|
||
| return ( | ||
| <> | ||
| <SidebarHeader className="h-16 px-4 py-0"> |
There was a problem hiding this comment.
This loses the previous UI's Site selector (modeled after Portainer) which displays the correct "Containers" (for all users) and "Nodes" (for admins) links depeneding on the last active site. This make navigating the UI a challenge once in a site.
|
|
||
| const mutation = useMutation({ | ||
| mutationFn: (values: FormData) => | ||
| api.post<{ imported: number }>(`/api/v1/sites/${siteId}/nodes/import-proxmox`, values), |
There was a problem hiding this comment.
This route is not defined, it appears it should be /import not /import-proxmox
| const { isCollapsed, isMobileViewport } = useSidebar(); | ||
| const isAdmin = !!session?.isAdmin; | ||
| const mfaAdminUrl = | ||
| isAdmin && session?.pushNotificationUrl ? `${session.pushNotificationUrl}/admin` : null; |
There was a problem hiding this comment.
Ignore that previous comment on this item. The Sidebar setting is there, but its underneath "Sites" rather than its previous location underneath "Settings"
| <code className="rounded bg-(--color-surface-2,#f5f5f5) px-2 py-1 font-mono text-sm break-all"> | ||
| {created.key} | ||
| </code> |
There was a problem hiding this comment.
This text box is unreadable in darkmode since the background is still white but the text is also white
| <TableCell className="font-mono text-xs"> | ||
| {c.sshHost && c.sshPort ? `${c.sshHost}:${c.sshPort}` : '—'} | ||
| </TableCell> |
There was a problem hiding this comment.
This loses our Open in VSCode (vscode-remote://) and Open in Terminal (ssh://) links
| }; | ||
| }, []); | ||
|
|
||
| function startPolling(id: string) { |
There was a problem hiding this comment.
Again not sure why but about 4/5 times the redirect to the next screen fails and sends me back to the login screen instead of on to the login screen. Editing the URL (not just refresshing) will get me the the next screen so it seems auth is working just not the redirect.
| if (process.env.NODE_ENV === 'production') { | ||
| throw new ApiError(404, 'not_found', 'Not found'); | ||
| } |
There was a problem hiding this comment.
I prefer how this was handled previously, where the route didn't even exist if we were in NODE_ENV production. This "single-button" sign on makes be nervous that itll leak into prod.
| // --- Mount Routers --- | ||
| const loginRouter = require('./routers/login'); | ||
| const registerRouter = require('./routers/register'); | ||
| const verifyRouter = require('./routers/verify'); |
There was a problem hiding this comment.
We still need the /verify route. It's used in the nginx template to enable HTTP Proxy Auth. The current build of this is returning 200 for all requests to /verify effectively disabling it for all containers that are relying on it.
| <TableCell> | ||
| <Badge variant={statusVariant(c.status)}>{c.status}</Badge> | ||
| </TableCell> | ||
| <TableCell>{c.nodeName || '—'}</TableCell> |
There was a problem hiding this comment.
This is supposed to have a link to the {node.apiUrl}/ in a new tab.
| <Select | ||
| label="Type" | ||
| value={svc.type} | ||
| onValueChange={(v) => | ||
| setValue( | ||
| `services.${idx}.type`, | ||
| v as FormData['services'][number]['type'], | ||
| ) | ||
| } | ||
| options={SERVICE_TYPES} | ||
| /> | ||
| <Input | ||
| label="Internal port" | ||
| type="number" | ||
| inputMode="numeric" | ||
| {...register(`services.${idx}.internalPort`)} | ||
| /> | ||
| <Button | ||
| type="button" | ||
| variant="ghost" | ||
| size="icon" | ||
| onClick={() => { | ||
| if (svc.id) setValue(`services.${idx}.deleted`, true); | ||
| else services.remove(idx); | ||
| }} | ||
| aria-label="Remove service" | ||
| > | ||
| <Trash2 className="size-4" /> | ||
| </Button> | ||
| </div> | ||
| {(svc.type === 'http' || svc.type === 'https') && ( | ||
| <div className="grid gap-3 sm:grid-cols-2"> | ||
| <Input | ||
| label="External hostname" | ||
| placeholder="app" | ||
| {...register(`services.${idx}.externalHostname`)} | ||
| /> | ||
| <Select | ||
| label="External domain" | ||
| value={svc.externalDomainId || ''} | ||
| onValueChange={(v) => | ||
| setValue(`services.${idx}.externalDomainId`, v) | ||
| } | ||
| options={domainOptions} | ||
| /> | ||
| <Switch | ||
| label="Require authentication" | ||
| checked={!!svc.authRequired} | ||
| onCheckedChange={(c) => | ||
| setValue(`services.${idx}.authRequired`, c) | ||
| } | ||
| /> | ||
| </div> | ||
| )} | ||
| {svc.type === 'srv' && ( | ||
| <Input | ||
| label="DNS name" | ||
| placeholder="_service._tcp.example" | ||
| {...register(`services.${idx}.dnsName`)} | ||
| /> | ||
| )} | ||
| </div> |
There was a problem hiding this comment.
The API only supports changing the "Require Auth" flag for existing services. The old UI had an affordance for this by disabling the fields (except for "Delete") and changes had to be made by deleting and adding a new service. If we can support modifying services with this model that's great but the API needs updated to support that or the UI needs updated to indicate it's not supported (by disabling the fields for existing services).
| <div className="grid gap-4 sm:grid-cols-2"> | ||
| <Input | ||
| label="DHCP range" | ||
| placeholder="10.0.0.100-10.0.0.200" |
There was a problem hiding this comment.
This is rendered as is into dnsmasq so it should use a comma as the seperator rather than the -.
| /> | ||
| <Input | ||
| label="DNS forwarders" | ||
| placeholder="8.8.8.8 1.1.1.1" |
There was a problem hiding this comment.
Likewise, this is rendered directly into dnsmasq so it should use a comma rather than a space for the seperator.
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