Skip to content

feat: Phase 5b — dashboard for tenants and platform operators - #7

Closed
TheMeinerLP wants to merge 13 commits into
feat/phase-5-apifrom
feat/phase-5b-ui
Closed

feat: Phase 5b — dashboard for tenants and platform operators#7
TheMeinerLP wants to merge 13 commits into
feat/phase-5-apifrom
feat/phase-5b-ui

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Phase 5b of Apus: the dashboard. Stacked on PR #6.

Nuxt 4 in SPA mode, Vue 3, Tailwind 4, Nuxt UI — matching the launchpad house standard. Two levels, separated by the role in the token: tenants manage their own sources, maps, renders and hosting; platform operators manage tenants, quotas and allowed domains.

Two things done deliberately

Frontend role checks are convenience, not security. The dashboard hides what a role may not do, but enforcement stays in the API. Nothing here is built to look like the frontend enforces rights — that invites dropping the server-side check later.

Progress is displayed honestly. Telemetry can degrade without the render being at risk; the API then reports -1 and degraded: true. The UI says so instead of drawing a bar at zero or inventing a value. That distinction is why phase 1 has a contract test in the first place.

Tokens live in memory only — not localStorage, not sessionStorage. A bearer JWT in Web Storage is scrapable by any XSS for as long as it sits there. Cost: a hard reload drops it, covered by silent renewal against the broker's own session.

Bugs found while building

A runtime error in the shared layout. Nuxt registers components from subdirectories with a directory prefix, so components/layout/AppHeader.vue is LayoutAppHeader in templates. The layout referenced <AppHeader />, which compiles to a resolveComponent that fails at runtime — and neither vue-tsc nor nuxt build reports it. Both parallel agents hit this independently.

It is now guarded by a test that actually mounts the layout and fails if a component does not resolve. That guard was itself validated: the fix was reverted, the test failed exactly as the original bug manifested, then the fix was restored. A typecheck demonstrably does not catch this.

Two API gaps that made the platform level unusable. There was no way to change an existing tenant's quota (only GET and POST /api/tenants), and no cluster-wide view of running renders — GET /api/renders always resolves the namespace from the caller's own tenant. Both contradict spec §10.3, which grants platform-admin exactly those abilities. The agent surfaced them as visible notices rather than faking a working screen, and both are now implemented.

The cluster-wide endpoint is the one place in the API that crosses tenant boundaries, so it is hard-restricted to platform-admin with a test proving every other role is refused. That kind of endpoint is precisely the hole that had to be closed three times already, in phases 2a, 3 and 5a.

Testing

107 frontend tests plus lint, typecheck and a production build with a runtime smoke test; API tests green including the new endpoints.

Known gaps

  • The OIDC flow has never run against a real broker — none is deployed yet (spec §15 still open).
  • The cluster-wide render table shows a snapshot with a refresh button; the live SSE streams stayed tenant-scoped.
  • Editing a tenant covers quota and allowed domains, not the display name.

… client

Adds the ui/ module (Nuxt 4, ssr:false, Vue 3, Tailwind 4 via @nuxt/ui, VueUse,
eslint-plugin-vuejs-accessibility) that the platform and tenant dashboards build on next.

- Client-only OIDC (oidc-client-ts, Authorization Code + PKCE) with tokens kept in an
  in-memory store rather than localStorage/sessionStorage, since an XSS bug in the UI would
  otherwise leak bearer access to a tenant's data; silent renew covers the reload cost.
- A typed client for every api module REST/SSE endpoint (tenants, sources, maps, renders,
  hostings, plus the two SSE progress/log streams), with request/response types mirrored
  field-for-field from the actual Java records and one ApusApiError shape for every failure
  mode the api module can produce.
- UI-side role helpers mirroring ApusPrincipal/TenantAccess -- convenience for what to show,
  not enforcement; the api module remains the sole enforcement point.
- A base layout/nav that shows the platform link only for platform-admin, and the one
  required page (signed-in user + tenant).
- Vitest unit tests for the API client, role logic, SSE framing, and JWT decoding (47 tests).

Not wired into the Gradle build (no build.gradle.kts, not in settings.gradle.kts) -- it's a
self-contained pnpm project with no shared build inputs/outputs with the Java modules; see
ui/README.md for the build/test commands and the full reasoning behind every decision above.
Adds the tenant-level view over the api module's REST/SSE surface (design
spec §11.2): read-only source/hosting listings, a map list with a
role-gated render trigger, and a render history with live percent/ETA
progress and log tailing over SSE. Progress display treats an unknown
percent/ETA (api sends -1 with degraded: true) as an honest "unknown"
state rather than a fabricated 0% or invented duration, and every SSE
stream closes itself on view-unmount or terminal render phase.
Adds the platform-admin dashboard (design spec §11.2): a tenant list
showing observed storage usage against quota, a form to create tenants
with their initial quota and allowed hosting domains, and a notice
explaining why cluster-wide job visibility isn't available yet. Hidden
from non-platform-admins as a UX convenience only -- the api module's
403 remains the actual enforcement point.

Storage usage is presented strictly as an observation (Ceph enforces
the quota, not Apus), and the allowed-domains help text spells out
what the list actually gates, so a bare "*" isn't set by accident.

Editing an existing tenant's quota or domains, and a cluster-wide
render listing, both need endpoints the api module does not expose
yet (only GET/POST /api/tenants exist, and GET /api/renders is scoped
to the caller's own tenant) -- reported upstream rather than worked
around here.
Nuxt registers components under nested directories with a directory prefix
(app/components/layout/AppHeader.vue -> LayoutAppHeader), but
layouts/default.vue referenced <AppHeader /> and AppHeader.vue itself
referenced <AppNav /> -- both unprefixed. Neither vue-tsc nor nuxt build
flags an unresolved component tag; Vue's template compiler treats it as a
plain, empty custom element instead. The header (and therefore the nav
inside it) silently failed to render at runtime.

Add a Nuxt-environment Vitest project (vitest.nuxt.config.ts,
tests/nuxt/) that actually mounts the default layout via
@nuxt/test-utils' mountSuspended and asserts the header/nav content is
present with no "Failed to resolve component" warning -- a real render,
not a typecheck, so this class of bug fails a test run again if it
recurs.
AppNav.vue only ever showed a "Platform" link (platform-admin). An
account with a tenant role (tenant-owner/-operator/-viewer) had no way
into /tenant except a typed-in URL. Add a "Tenant" link gated on
canReadTenant(), mirroring the existing platform-admin gate.
…nders cluster-wide

Closes two gaps between design spec §10.3's role table ("platform-admin
darf Tenants anlegen/ändern/löschen, Quotas, clusterweite Sicht") and
what the api module actually implemented, both flagged by the platform
dashboard work rather than worked around:

- PATCH /api/tenants/{name}: TenantRepository gained update(Tenant), used
  by a new TenantController#update that changes storage quota/maxObjects
  and allowedHostingDomains on an existing tenant (partial-update
  semantics, null leaves a field untouched). platform-admin only, same
  manual role check as list/create.

- GET /api/renders/cluster: BlueMapRenderController#listCluster walks
  every Tenant via TenantRepository (cluster-wide reach, exactly like
  TenantController already has) and lists renders in each tenant's own
  namespace, tagging each with its owning tenant (ClusterRenderResponse).
  Does not touch TenantResolver -- a platform-admin is not necessarily a
  member of any tenant, so resolving *a* namespace for it would be wrong.

Both endpoints keep the module's one binding security rule: the tenant
comes from the token or, for the cluster endpoint, from iterating every
Tenant resource -- never from anything the caller supplies. Added
BlueMapRenderControllerTest#listClusterRejectsEveryNonPlatformAdminRole,
which asserts tenant-owner/-operator/-viewer and a roleless caller are
all rejected, not just "no roles at all".
…o utils/

Nuxt's components/ auto-scan registers every file under it as a
component, including plain .ts logic modules -- formatTimestamp.ts,
renderProgress.ts, and sseController.ts under components/tenant/ showed
up as pseudo-components (TenantFormatTimestamp etc.) in
.nuxt/components.d.ts. Harmless (nothing referenced those names as a
component) but a smell inherent to putting non-component files under
components/, called out in both files' own comments as a leftover of an
earlier file-scope restriction. Move them to app/utils/, alongside the
project's other framework-free logic, and update every import site and
test accordingly.
… the platform dashboard

Removes the two honest "not available yet" notices the platform
dashboard shipped with and wires the real endpoints added in the
preceding api commit:

- PlatformEditTenantForm.vue (new): per-tenant inline form for storage
  quota, max objects, and allowed hosting domains, calling
  PATCH /api/tenants/{name} via the new ApusApiClient#updateTenant.
  Wired into TenantList.vue behind an "Edit quota / domains" toggle, one
  open at a time; removes the "quota/domains can only be set at
  creation" notice from CreateTenantForm.vue, which is no longer true.

- PlatformClusterRenderTable.vue (new, replaces ClusterJobsNotice.vue):
  fetches GET /api/renders/cluster via the new
  ApusApiClient#listClusterRenders and lists every tenant's renders with
  phase/progress/start time. Deliberately a snapshot, not a live view --
  the per-render SSE stream still resolves its namespace through the
  caller's own tenant claim, which a platform-admin viewing another
  tenant's render does not have; a "Refresh" button re-fetches instead.

Also finishes moving components/platform/domainValidation.ts to
app/utils/ (paired with storageUsage.ts's move in the layout-fix commit),
closing out the same components/-as-logic-folder smell for the platform
side that the preceding refactor commit closed for the tenant side.
…onfig

The strings the secret scanner flagged were not only in old commits: the
phase 1 plan carried them in a code sample, and .gitguardian.yaml listed
them in plaintext, which made the exemption file a finding of its own.

The plan sample now shows a placeholder, and the exemptions are SHA256
digests.
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

Closing in favor of #15: same content, rebuilt as a single squash commit on a new clean/* branch because a secret scanner flagged disposable test credentials in this PR's commit history and history cannot be rewritten in this environment. See #15 for detail.

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.

1 participant