Skip to content

Rewrite the Service Management Portal as a React SPA - #28

Open
Paul Lizer (paullizer) wants to merge 2 commits into
mainfrom
paullizer-frontend-tailwind-migration
Open

Rewrite the Service Management Portal as a React SPA#28
Paul Lizer (paullizer) wants to merge 2 commits into
mainfrom
paullizer-frontend-tailwind-migration

Conversation

@paullizer

@paullizer Paul Lizer (paullizer) commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Replaces the Flask/Jinja/Bootstrap portal with a React 18 + TypeScript single-page app built with Vite and Tailwind CSS v4, using a custom glassmorphism design system.

Architecture

Flask stays, but as a backend-for-frontend.

flowchart LR
    B[Browser<br/>React SPA] -->|session cookie<br/>+ X-CSRFToken| F[Flask BFF]
    F -->|/login /getAToken /logout| E[Entra ID]
    F -->|Bearer token from<br/>server-side session| A[Broker API]
    F -->|static/dist| B
Loading
  • Authentication is unchanged. The MSAL confidential client flow, the server-side session, and the token that never reaches the browser are all as they were. No Entra app registration changes were needed — no SPA redirect URIs, no PKCE, no new scopes.
  • The route_*.py modules now return JSON under /api/ui/*. Flask serves the built bundle for every non-API path, so bookmarked deep links and hard refreshes still resolve.
  • CSRF is preserved. The client reads the token from /api/ui/session and returns it as an X-CSRFToken header, which Flask-WTF already accepts.

Client routes mirror the URLs the Jinja portal served, so existing bookmarks and runbook links keep working.

Scope

New front_end/web/ — 70 files. React Router + TanStack Query, all 14 pages, 37 inline SVG icons, design system in one CSS file.
Rewritten app.py, all route modules, function_authentication.py, function_api.py, Dockerfile, the pytest suite, front_end/README.md.
Deleted 20 Jinja templates, the vendored Bootstrap 5.3 distribution, app.css, app.js, route_user.py.

154 files changed: +13,197 / −63,782.

Behaviour changes worth reviewing

  • History filters moved from the Flask session into the URL. A filtered view is now bookmarkable and shareable, two browser tabs no longer clobber each other's criteria, and three near-identical POST-redirect-GET blocks are gone. The client and the BFF clamp page/per_page identically so they can't disagree.
  • Flash messages became toasts in an aria-live region.
  • No-JavaScript support is lost. The old portal degraded to working HTML forms; a SPA cannot. This is inherent to the requested stack and is called out in the README.

Bugs found and fixed while testing the rewrite

  1. NavLink overrode the explicit aria-current, so "Scaling Management" was not highlighted on /scaling/log. This is the same bug the scaling_endpoints list in base.html existed to prevent, so the nav now does its own section matching with a plain Link.
  2. The confirm dialog's focus trap filtered candidates on offsetParent, collapsing the list to one element and stopping Tab from wrapping.
  3. .dockerignore patterns only matched at the build context root, so a nested node_modules or .venv would have been copied into the image. flask_session is now excluded too, because those files hold live access tokens.
  4. /api/ui/session reported authenticated: true with an all-null user when session['user'] was not a mapping.

Accessibility

Glassmorphism is easy to make unreadable, so these are enforced rather than assumed:

  • Text sits on a surface opaque enough for at least 4.5:1 contrast. Blur is decoration, never the only separation from the backdrop.
  • prefers-reduced-transparency and prefers-reduced-motion fall back to solid surfaces, and there is a matching @supports not (backdrop-filter: ...) fallback.
  • Status is never conveyed by colour alone — every badge pairs colour with an icon and text, pinned by Badge.test.tsx.
  • The skip link, the 3px :focus-visible ring, and the pre-paint theme script are all retained.

No-CDN policy

Still intact at runtime: everything is bundled locally, fonts are the system stack, and icons are inline SVG. Audited the built output — the only external strings are data: URIs and library license banners.

⚠️ Build time is different. front_end/Dockerfile is now multi-stage: a node:22-alpine stage runs npm ci && npm run build, and only the compiled bundle is copied into the Python image. That means az acr build now needs npm registry access. Documented in deploy/DEPLOYMENT.md with internal-mirror guidance. Nothing generated is committed; package-lock.json is, so npm ci is reproducible and the dependency set is reviewable.

Testing

Everything the previous suite protected is still covered, on whichever side now owns it.

Check Result
pytest (JSON contract) 135 passed
Vitest (10 files) 103 passed
tsc --build clean
npm run build 307 KB JS / 33 KB CSS
Live Flask server smoke test shell, deep links, assets, 401/404 JSON all correct
  • pytest keeps: date conversion (YYYY-MM-DDMM/DD/YYYY), ignore-filter semantics, pagination parameters, the legacy bare-list fallback, dashboard summary preference and degradation, CSRF, and the error-status mapping.
  • Vitest takes: VM lifecycle rules, pagination windowing, table sort and filter, the filter round-trip through the URL, badge accessibility, confirm-dialog behaviour, theme persistence, and the error page.
  • App.test.tsx mounts the real app against a stubbed BFF and walks every authenticated route, so a page that throws on mount or misuses a hook fails in CI rather than in a browser.

CI gains a Node job (install, typecheck, Vitest, production build) alongside the existing pytest job. The Python job deliberately needs no Node toolchain: a spa_bundle fixture supplies a stand-in shell for the tests that assert Flask serves the bundle, so they state that dependency rather than inheriting it from whether someone happened to run a build. The second commit fixes exactly that — the first push failed CI for this reason, which also surfaced an untested branch: a missing bundle now returns a 500 naming the command to run.

Not touched

api/, deploy/bicep/, app registrations, redirect URIs, scopes, and the database schema.

Replace the Flask/Jinja/Bootstrap portal with a React 18 + TypeScript
single-page app built with Vite and Tailwind CSS v4, using a custom
glassmorphism design system.

Flask stays, but as a backend-for-frontend. The MSAL confidential client
flow is unchanged: the access token lives in the Flask session and never
reaches the browser, so no Entra app registration changes were needed. The
route modules now return JSON under /api/ui/*, and Flask serves the built
bundle for every non-API path so bookmarked deep links and hard refreshes
still resolve. Flask-WTF CSRF protection is preserved; the client reads the
token from /api/ui/session and returns it as an X-CSRFToken header.

All 14 pages are ported. The 37 hand-authored inline SVG icons carry over,
so the portal still has no icon-font dependency. Fonts are the system stack
and every asset is bundled locally, which keeps the no-CDN requirement for
Azure Government, sovereign and air-gapped clouds intact at runtime.

Deleted: 20 Jinja templates, the vendored Bootstrap 5.3 distribution,
static/css/app.css, static/js/app.js, and route_user.py, whose only job was
rendering the profile page from session claims.

Behaviour changes worth knowing:

- History filters move from the Flask session into the URL. A filtered view
  is now bookmarkable and shareable, two browser tabs no longer clobber each
  other's criteria, and three near-identical POST-redirect-GET blocks are
  gone. The client and the BFF clamp page and per_page identically.
- Flash messages become toasts in an aria-live region.
- No-JavaScript support is lost. The old portal degraded to working HTML
  forms; a single-page app cannot.

Fixes found while testing the rewrite:

- NavLink overrode the explicit aria-current, so Scaling Management was not
  highlighted on /scaling/log. This is the same bug the scaling_endpoints
  list in base.html existed to prevent, so the nav now does its own section
  matching with a plain Link.
- The confirm dialog's focus trap filtered candidates on offsetParent, which
  collapsed the list to a single element and stopped Tab wrapping.
- .dockerignore patterns only matched at the build context root, so a nested
  node_modules or .venv would have been copied into the image. flask_session
  is now excluded too, because those files hold live access tokens.
- /api/ui/session reported authenticated: true with an all-null user when
  session['user'] was not a mapping.

Build and test:

- front_end/Dockerfile is multi-stage. A node:22-alpine stage runs npm ci
  and npm run build, and only the compiled bundle is copied into the Python
  image. Nothing generated is committed; package-lock.json is, so npm ci is
  reproducible. Note that building the image now requires npm registry
  access, which is documented in deploy/DEPLOYMENT.md.
- The pytest suite is rewritten against the JSON contract, and a Vitest and
  React Testing Library suite covers the client. Both run in CI. Everything
  the previous suite protected is still covered, on whichever side now owns
  it. App.test.tsx mounts the real app against a stubbed BFF and walks every
  authenticated route.

pytest: 134 passed. Vitest: 103 passed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread front_end/function_bff.py


def json_error(message, status=502, **extra):
return jsonify({"error": message, **extra}), status
The three tests that assert Flask serves the built bundle read
static/dist/index.html, which is a build artifact. They passed locally only
because a build happened to be present, and failed in the Python CI job,
which has no Node toolchain and never produces one.

Add a spa_bundle fixture that supplies a stand-in shell when no build
exists, and leaves a real build untouched. These tests are about Flask's
routing and headers rather than the bundle's contents, so they now state
that dependency instead of inheriting it, and the Python suite stays free of
Node.

Also covers the branch this exposed: a missing bundle returns a 500 that
names the command to run, which was previously untested.

pytest: 135 passed, both with and without a build present.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants