19e1a4fca3
- Add _verify-token.get.ts to both operator and portal — decodes the access token stored in the nuxt-oidc-auth session and echoes iss/aud/ sub/groups. Used to confirm operator tokens carry aud=dezky-operator and portal tokens carry aud=dezky-portal. Listed in NEXT-STEPS.md as throwaway, to be removed when proper verification surfaces exist. - OPERATOR-PLAN.md O.9 marked done with the actual claims captured + the Mongo-side verification of attach + suspend flows. - NEXT-STEPS.md: replaced the "Operator portal — out-of-band track" section with a "shipped + follow-ups" version. The 9-item follow-up list (impersonation, audit, flags, incidents, support, partner portal, env switcher, on-call, workspace impersonation) is now the authoritative roadmap, not buried inside OPERATOR-PLAN.md.
515 lines
25 KiB
Markdown
515 lines
25 KiB
Markdown
# Operator Portal — Plan
|
||
|
||
`operator.dezky.local` (dev) → `operator.dezky.com` (prod). Internal admin portal
|
||
for Dezky staff: managing tenants, partners, operating the platform.
|
||
|
||
Distinct from the customer portal at `app.dezky.local`. Different OAuth client,
|
||
different cookie domain, different surface — though they share Authentik as the
|
||
IdP and (eventually) platform-api as the backend.
|
||
|
||
This file is the running record of decisions made during the design grilling
|
||
session. Updated inline as questions resolve.
|
||
|
||
---
|
||
|
||
## Scope — C-visual with real management for Tenants + Partners
|
||
|
||
Decision: build every screen from the source design visually, but back two
|
||
domains with real CRUD from day one — Tenants and Partners. Everything else
|
||
renders against mock-data fixtures until its backend is built.
|
||
|
||
| Surface | Day-1 state |
|
||
|---|---|
|
||
| Overview / dashboard | Visual — aggregates from real Tenant+Partner data where available, mock for the rest |
|
||
| Tenants (list + detail with 7 tabs) | **Real backend**, full CRUD, suspend/resume/delete |
|
||
| Partners (list + detail) | **Real backend**, new schema, full CRUD |
|
||
| Users (global) | Real read across tenants (already in DB) |
|
||
| Support queue | Mock |
|
||
| Platform billing | Mock |
|
||
| Reports | Mock |
|
||
| Infrastructure | Visual; could derive from Docker health checks but probably mock initially |
|
||
| Feature flags | Mock |
|
||
| Audit log | Mock (real backfill is a follow-up) |
|
||
| Operator team | Real (Users with `platformAdmin: true`) |
|
||
| Platform settings | Mock |
|
||
| Command palette ⌘K | Visual — opens, navigates, but "execute action" just toasts |
|
||
| Impersonation modal + banner | Visual — confirms the action but doesn't actually mint a token |
|
||
| Incident modal | Mock |
|
||
| Env switcher (prod/staging/dev) | Cosmetic — picks a label, no real env switch |
|
||
| On-call indicator | Mock |
|
||
|
||
### Real-backend surface this adds
|
||
|
||
Two genuinely new things on the backend:
|
||
|
||
1. **Partner schema and CRUD** in `services/platform-api` — id, name, domain,
|
||
status, customers count (computed), MRR (computed), margin, sinceDate. Tenants
|
||
gain an optional `partnerId` field. The existing `dezky` seed gets no partner.
|
||
2. **Tenant lifecycle actions** beyond create — suspend, resume, change plan,
|
||
change seat cap, soft-delete with grace period. Existing schema covers most
|
||
of this; controllers need new methods.
|
||
|
||
Everything else (incidents, flags, support tickets, audit log collection,
|
||
impersonation tokens) stays mock until explicitly promoted.
|
||
|
||
---
|
||
|
||
## Lives at `apps/operator/` — separate Nuxt app
|
||
|
||
Decision: new Nuxt 3 app, separate `package.json`, separate Traefik route at
|
||
`operator.dezky.local`. Reuses design tokens / NodeMark / UiIcon by copy for
|
||
now; a `packages/ui` workspace is a likely follow-up once we have a third
|
||
consumer.
|
||
|
||
**Why separate, not a route group in `apps/portal/`:** security boundary. The
|
||
moment any operator-only feature mutates customer state (impersonation, suspend
|
||
tenant), a routing or middleware bug on a shared app is catastrophic. Separate
|
||
apps make that nearly impossible. Different cookies, different OIDC client,
|
||
different domain.
|
||
|
||
**Cost:** one more docker-compose service, ~10 lines of Traefik labels, one more
|
||
volume for `node_modules`. Some duplicated dev tooling (eslint, tsconfig).
|
||
|
||
---
|
||
|
||
## Auth — new `dezky-operator` Authentik OAuth provider
|
||
|
||
Decision: a dedicated OAuth client in Authentik, distinct from `dezky-portal`.
|
||
|
||
- New provider `dezky-operator` (confidential, PKCE on)
|
||
- Redirect URIs: `https://operator.dezky.local/auth/oidc/callback`
|
||
- Group binding: `dezky-platform-admins` required at the provider's authorization
|
||
flow (Authentik policy), so non-admins can't even consent
|
||
- Stricter policies attached only to this provider: MFA required, future IP
|
||
allowlist for the office network/VPN
|
||
- Token audience claim: `dezky-operator`
|
||
- Provisioning's `JwtAuthGuard` widens its audience check to a list:
|
||
`['dezky-portal', 'dezky-operator']`
|
||
- Per-endpoint guard for operator-only mutations: require `aud === 'dezky-operator'`
|
||
AND `actor.platformAdmin === true`. The audience check makes "is this a privileged
|
||
session" provable from the token alone, independent of the DB lookup
|
||
|
||
**UX trade-off accepted:** if Ronni (or any operator who is also a customer)
|
||
wants to be in both apps, they log into Authentik twice — once per audience.
|
||
Correct security-wise, fine ergonomically.
|
||
|
||
---
|
||
|
||
## Backend stays as one service — rename to `services/platform-api`
|
||
|
||
Decision: route all operator mutations and reads through the existing NestJS
|
||
service (no second backend, no Nitro-direct-to-Mongo). Rename
|
||
`services/provisioning` → `services/platform-api` because the service now owns
|
||
more than just provisioning — it's the platform's data + control plane.
|
||
|
||
**What changes during the rename:**
|
||
|
||
- Directory: `services/provisioning/` → `services/platform-api/`
|
||
- Package: `@dezky/provisioning` → `@dezky/platform-api`
|
||
- Docker container name: `dezky-provisioning` → `dezky-platform-api`
|
||
- Compose service key, network alias, volume names
|
||
- Portal env var: `PROVISIONING_INTERNAL_URL` → `PLATFORM_API_INTERNAL_URL`
|
||
- Portal proxy routes: `http://provisioning:3001` → `http://platform-api:3001`
|
||
- Internal module names referencing "provisioning" stay (e.g.
|
||
`ProvisioningService` is now one orchestration concern *inside*
|
||
`platform-api`; not the whole service's purpose)
|
||
- Public URL stays `api.dezky.local` (Traefik routes by Host header, unaffected)
|
||
|
||
**New endpoints platform-api gains in this phase:**
|
||
|
||
- `POST /tenants/:slug/suspend`, `POST /tenants/:slug/resume`
|
||
- `PATCH /tenants/:slug` already exists; ensure it can change plan / seat cap
|
||
- `GET /partners`, `POST /partners`, `GET /partners/:slug`, `PATCH /partners/:slug`
|
||
- `Tenant.partnerId` foreign key + filter on tenant queries
|
||
- `JwtAuthGuard` accepts both `dezky-portal` and `dezky-operator` audiences;
|
||
per-endpoint requirement of `dezky-operator` aud for operator-only mutations
|
||
|
||
**Strategy:** rename in a separate prep commit before the operator work starts,
|
||
so the rename diff is mechanical and reviewable on its own.
|
||
|
||
---
|
||
|
||
## Partner schema
|
||
|
||
```typescript
|
||
@Schema({ collection: 'partners', timestamps: true })
|
||
class Partner {
|
||
slug: string // 'nordicmsp', URL-safe, unique
|
||
name: string // 'NordicMSP'
|
||
domain: string // 'nordicmsp.dk' — partner's own org domain
|
||
status: 'active' | 'in-negotiation' | 'paused' | 'terminated' // default 'in-negotiation'
|
||
marginPct: number // 20 = partner keeps 20% of customer MRR; one number per partner
|
||
partnershipStartedAt?: Date
|
||
contactInfo: { primaryName?, primaryEmail?, billingEmail? }
|
||
billingInfo: { /* same shape as Tenant.billingInfo */ }
|
||
}
|
||
```
|
||
|
||
**Tenant side:** add `partnerId?: Types.ObjectId` (ref Partner, indexed,
|
||
optional). Direct customers have no `partnerId`; partner-owned customers
|
||
reference one.
|
||
|
||
**Computed at query time, not stored:**
|
||
- `Partner.customers` — count of tenants with `partnerId === this._id`
|
||
- `Partner.mrr` — sum of those tenants' MRR
|
||
|
||
Storing denormalized would force write-time syncing on every tenant
|
||
create/suspend/plan-change for ~zero benefit at our scale.
|
||
|
||
**Operator-only.** A self-serve partner portal at `partner.dezky.local` is a
|
||
future surface; not in this phase. Partners are visible/manageable only from
|
||
the operator app.
|
||
|
||
---
|
||
|
||
## Impersonation — visual stub now, real flow later
|
||
|
||
Decision: build the UI exactly as designed (modal with reason field, top
|
||
banner, exit button) but do not wire actual token exchange. The confirm action
|
||
toasts "impersonation not implemented yet" and writes a mock audit entry.
|
||
|
||
**Why now:** validates the UX, lets future hires see the operator surface
|
||
end-to-end, doesn't introduce a dangerous capability before there's an
|
||
operational need.
|
||
|
||
**Mitigations against confusion:**
|
||
- Modal carries a `Demo only` badge — same styling as other stub-data badges
|
||
in the operator UI
|
||
- Toast on confirm makes the no-op explicit
|
||
- The banner does display in mock mode (so we can iterate on its design), but
|
||
the underlying session state is local to the operator tab
|
||
|
||
**Real flow design recorded for the follow-up:** OAuth 2 Token Exchange
|
||
(RFC 8693). Authentik supports it. Customer portal needs to accept tokens
|
||
carrying an `act` claim alongside `sub`, and show its own impersonation banner
|
||
when the two differ. ~2 days of careful work + security review.
|
||
|
||
---
|
||
|
||
## Decisions made without grilling (small, low-risk)
|
||
|
||
- **Theme:** dark by default. Existing `apps/portal/assets/styles/tokens.css`
|
||
already defines `[data-theme='dark']` tokens; the operator app sets
|
||
`<html data-theme="dark">` at app root and reuses them
|
||
- **Mock data location:** TypeScript files under `apps/operator/data/`
|
||
(`tenants-mock.ts`, `partners-mock.ts`, `flags-mock.ts`, etc.). Same shape
|
||
as `operator-data.jsx` from the design bundle, just retyped
|
||
- **Design system reuse:** copy `NodeMark.vue`, `UiIcon.vue`, and the auth
|
||
components into `apps/operator/components/` directly. A shared `packages/ui`
|
||
workspace becomes worth doing once a third surface needs them (partner
|
||
portal? landing site?)
|
||
- **OCIS / Stalwart admin shortcuts in operator UI:** out of scope for this
|
||
phase. Operator drills via the customer-facing service URLs
|
||
|
||
---
|
||
|
||
## Follow-up tasks (post-MVP)
|
||
|
||
In rough priority order:
|
||
|
||
1. **Real impersonation flow** — OAuth Token Exchange (RFC 8693), customer
|
||
portal `act`-claim handling, audit on entry+exit, banner with origin
|
||
operator identity
|
||
2. **Real audit log collection** — replace mock fixtures with a `platform_audit`
|
||
collection in Mongo that platform-api writes on every privileged action;
|
||
stream from there in the operator UI
|
||
3. **Feature flag backend** — `Flag` schema + per-tenant rollout state + a
|
||
tiny flag-eval client every service imports
|
||
4. **Incident management backend** — `Incident` schema + paging integration
|
||
(PagerDuty / OpsGenie / custom). Until then, the incident modal renders
|
||
from mock
|
||
5. **Support ticket queue** — `SupportTicket` schema + email-in ingestion
|
||
from a dedicated mailbox via Stalwart
|
||
6. **Self-serve Partner portal at `partner.dezky.local`** — Phase 6+ work,
|
||
own Nuxt app, own OAuth client, scoped to a partner's own customers
|
||
7. **Real environment switcher** — currently cosmetic; would need separate
|
||
API endpoints per env, separate Authentik tenants, etc.
|
||
8. **Real on-call indicator** — integration with the paging system that
|
||
gets installed in (4)
|
||
9. **Operator workspace impersonation in OCIS/Stalwart** — operator tooling
|
||
reaches *into* the customer's file storage and mail for support, with the
|
||
same audit trail as portal impersonation
|
||
|
||
---
|
||
|
||
## Out of scope for this entire effort
|
||
|
||
- Multi-region operator UI
|
||
- Read-only investor / board mode (a real persona but build it when there's a
|
||
real investor — design has a placeholder "Read-only" role for Jonas Berg)
|
||
- White-label of the operator portal (partners get their own portal eventually;
|
||
Dezky operator never gets white-labeled — it's our internal tool)
|
||
|
||
---
|
||
|
||
## Execution checklist
|
||
|
||
Tick boxes as work lands. Each phase is roughly one commit. Phases must be
|
||
done in order — earlier ones unblock later ones.
|
||
|
||
### O.0 · Prep — service rename ✓
|
||
|
||
- [x] Rename `services/provisioning/` → `services/platform-api/`
|
||
- [x] Update `package.json` name → `@dezky/platform-api`
|
||
- [x] Update `docker-compose.yml`: container name, service key, volume name,
|
||
env var `PROVISIONING_INTERNAL_URL` → `PLATFORM_API_INTERNAL_URL`,
|
||
NUXT_API_BASE points at new hostname
|
||
- [x] Update portal proxy routes to read `PLATFORM_API_INTERNAL_URL` and
|
||
default to `http://platform-api:3001`
|
||
- [x] Sweep docs (README, CLAUDE.md, SERVICES.md, AUTHENTIK-SETUP.md,
|
||
NEXT-STEPS.md, TROUBLESHOOTING.md) for stale references
|
||
- [x] Verify customer portal `/api/me` still works end-to-end after rename
|
||
|
||
### O.1 · Authentik — operator OAuth client ✓
|
||
|
||
- [x] Create `dezky-operator` OAuth provider via Authentik API
|
||
- [x] Set redirect URIs to `https://operator.dezky.local/auth/oidc/{callback,logout}`
|
||
- [x] Confidential client; client_secret persisted to `.env` as
|
||
`OPERATOR_OIDC_CLIENT_SECRET`
|
||
- [x] `Dezky Operator` application created and linked to the provider
|
||
- [x] Group binding on the application: `dezky-platform-admins` required to
|
||
reach the consent screen. (Authentik 2025.10 supports group-direct
|
||
policy bindings — no separate `policy_group_membership` object needed)
|
||
- [ ] **Deferred to follow-up:** MFA-required policy on this provider.
|
||
Authentik does this via a stage binding on the authentication flow,
|
||
which is app-specific configuration we'll wire when there's an actual
|
||
MFA enrollment to gate against. For dev with one akadmin, akadmin
|
||
already has WebAuthn — the auth flow prompts for it automatically
|
||
- [x] Discovery doc verified at
|
||
`/application/o/dezky-operator/.well-known/openid-configuration` —
|
||
issuer correct, scopes include `groups`, all endpoints resolve
|
||
|
||
### Gotchas worth noting
|
||
|
||
- Authentik 2025.10 requires both `authorization_flow` AND `invalidation_flow`
|
||
when creating OAuth2 providers. The default invalidation flow is at
|
||
`/api/v3/flows/instances/?designation=invalidation` (slug
|
||
`default-provider-invalidation-flow`)
|
||
- The `policies/group_membership/` endpoint mentioned in older Authentik
|
||
docs is gone in 2025.10. Use `policies/bindings/` with a direct `group`
|
||
reference instead
|
||
|
||
### O.2 · platform-api — multi-audience + Partner CRUD ✓
|
||
|
||
- [x] `JwtAuthGuard`: accepts comma-separated `AUTHENTIK_AUDIENCE`
|
||
(`dezky-portal,dezky-operator`). Both audiences validate; per-endpoint
|
||
guards further restrict
|
||
- [x] `OperatorGuard` (not a decorator — a regular `CanActivate` guard)
|
||
enforcing `aud includes 'dezky-operator' && actor.platformAdmin`.
|
||
Applied via `@UseGuards(JwtAuthGuard, OperatorGuard)`
|
||
- [x] `schemas/partner.schema.ts` — Partner model
|
||
- [x] `partners/` module: controller + service + DTOs (create / read /
|
||
update / soft-terminate / list tenants under partner)
|
||
- [x] `partnerId?: Types.ObjectId` added to Tenant schema (indexed, sparse).
|
||
`UpdateTenantDto` accepts `partnerId` to attach/detach
|
||
- [x] `Partner.customers` aggregated at query time (count of Tenants by
|
||
partnerId). MRR aggregation **deferred** — Tenant has no monthly
|
||
amount yet and Subscription lacks a price column. Will land when
|
||
Subscription gains pricing
|
||
- [x] Tenant lifecycle endpoints: `POST /tenants/:slug/suspend`,
|
||
`POST /tenants/:slug/resume` (operator-only). PATCH already accepts
|
||
plan/domains/partnerId changes
|
||
- [x] Smoke test: customer-portal token → `POST /partners` returns 403
|
||
"This endpoint requires an operator-scoped token" ✓. Positive test
|
||
(operator token → 200) deferred until O.3 when the operator app
|
||
exists to mint that token
|
||
|
||
### O.3 · Scaffold `apps/operator/` ✓
|
||
|
||
- [x] `apps/operator/package.json` (Nuxt 3, `nuxt-oidc-auth` 1.0.0-beta.11)
|
||
- [x] `nuxt.config.ts` wired against the `dezky-operator` Authentik provider:
|
||
`client_id=dezky-operator`, audience claim becomes `dezky-operator`,
|
||
scope includes `groups`, `exposeAccessToken: true` so the Nitro proxy
|
||
can forward it
|
||
- [x] Docker compose service `operator` running on the dezky network, mkcert
|
||
root CA mounted, Traefik route at `operator.dezky.local`
|
||
- [x] Network alias on Traefik: `operator.dezky.local`
|
||
- [x] `operator.dezky.local` added to `/etc/hosts`
|
||
- [x] Distinct session secrets in `.env` (`OPERATOR_NUXT_OIDC_*`) — the two
|
||
apps can't decrypt each other's session cookies
|
||
- [x] Verified login: signing in lands on the placeholder index showing
|
||
`Operator portal · placeholder` with the user's identity
|
||
- [x] Smoke test `POST /partners`: operator session returns 200 (partner
|
||
created in Mongo), idempotent re-call returns 409 (already exists),
|
||
customer-portal session returns 403 ("requires operator-scoped token")
|
||
- [x] `JwtAuthGuard` extended to accept **multi-issuer** as well as
|
||
multi-audience (each Authentik OAuth provider has its own per-app
|
||
`iss` URL); `AUTHENTIK_ISSUER` env is now comma-separated. The audience
|
||
change in O.2 wasn't enough on its own — issuer matching is separate
|
||
|
||
### O.4 · Design system + app shell ✓
|
||
|
||
- [x] `assets/styles/tokens.css` carbon-default (done in O.3)
|
||
- [x] `assets/styles/base.css` (done in O.3)
|
||
- [x] `NodeMark.vue` (copied unchanged from portal),
|
||
`UiIcon.vue` (expanded set: 31 icons covering sidebar/topbar/sort/arrows)
|
||
- [x] Shared primitives: `Card`, `UiButton` (5 variants × 3 sizes),
|
||
`DataTable`, `Badge` (7 tones), `Mono`, `Eyebrow`, `StatusDot`,
|
||
`Avatar` (deterministic palette), `PageHeader`
|
||
- [x] `OpSidebar.vue` — collapsible (232↔56px), 12 nav items in 4
|
||
sections, active-row highlight from route, badge slot per item,
|
||
brand mark + user identity footer
|
||
- [x] `OpTopbar.vue` — env badge (prod/staging/dev), ⌘K palette trigger
|
||
stub, on-call pill, bell, avatar
|
||
- [x] `layouts/default.vue` wires sidebar + topbar + `<slot />`;
|
||
`layouts/blank.vue` for the login page; `app.vue` uses `<NuxtLayout>`
|
||
- [x] Keyboard shortcut: ⌘[ collapses/expands sidebar (verified — width
|
||
flips 232↔56 in the browser via the toggle click). ⌘K palette lands
|
||
in O.8
|
||
- [x] Verified in browser: shell renders with all 12 nav links, env badge
|
||
shows PROD, PageHeader title resolves to the user's display name,
|
||
smoke test re-confirmed 409 on the seeded `test-partner` (token
|
||
forwarding still works after the layout refactor)
|
||
|
||
### Gotcha worth noting
|
||
|
||
- Vite 7.3 added a strict `server.allowedHosts` check that blocks any
|
||
Host header that isn't an exact match for the dev origin. The customer
|
||
portal was scaffolded under an older Vite and pre-dates this. Operator
|
||
needs `allowedHosts: ['operator.dezky.local']` in `nuxt.config.ts`
|
||
under `vite.server` or every request 403s with a plaintext error.
|
||
|
||
### O.5 · Tenant management (real backend) ✓
|
||
|
||
- [x] `pages/tenants/index.vue` — list with status/plan/domains/created/
|
||
provisioning-state columns; search by slug or name; status chips
|
||
(all / active / pending / suspended) with live counts; click-through
|
||
to detail
|
||
- [x] `pages/tenants/[slug].vue` — detail with 7 tabs (`Tabs` primitive)
|
||
- [x] Tab: **Overview** — Identity card + Billing card with key fields
|
||
- [x] Tab: **Users** — list users via `GET /api/tenants/:slug/users` (new
|
||
endpoint added to platform-api), lazy-loaded on first tab click
|
||
- [x] Tab: **Resources** — per-integration `Badge` + external handle
|
||
(group ID / mail domain / space ID) + last error if any; **Reconcile
|
||
now** button re-runs orchestration in place. Iterates the explicit
|
||
`INTEGRATIONS` array, not the raw Mongoose subdoc keys, so the
|
||
`_id` field doesn't leak into the UI
|
||
- [x] Tab: **Billing** (mock — plan + MRR + Stripe IDs as fixtures)
|
||
- [x] Tab: **Audit** (mock — three sample log entries)
|
||
- [x] Tab: **Support** (mock — placeholder)
|
||
- [x] Tab: **Danger zone** — three real-backend cards (Suspend / Resume /
|
||
Soft-delete) each gated by ConfirmDialog. Suspended verified live:
|
||
acme → `suspended` in Mongo, then Resumed back to `active`
|
||
|
||
### Server proxies added (apps/operator/server/api/tenants/)
|
||
|
||
`platform-api.ts` util encapsulates the access-token forwarding. Routes:
|
||
`index.get`, `[slug]/index.{get,patch,delete}`, `[slug]/users.get`,
|
||
`[slug]/{suspend,resume,reconcile}.post`. All read the operator's session,
|
||
forward as bearer to platform-api.
|
||
|
||
### New primitives this phase
|
||
|
||
`Tabs.vue` (horizontal strip with optional counts), `ConfirmDialog.vue`
|
||
(Teleport-to-body modal, Escape/backdrop close, danger/primary tone).
|
||
|
||
### Gotchas worth noting
|
||
|
||
- **Mongoose subdoc `_id` leaks into JSON**: iterating `v-for="(state, k)
|
||
in tenant.provisioningStatus"` includes `_id`. Iterate an explicit
|
||
whitelist (`['authentik', 'stalwart', 'ocis']`) instead.
|
||
- **Fields added to schema after document creation are missing in old
|
||
docs**: acme was created before `provisioningErrors` existed, so
|
||
`tenant.provisioningErrors[k]` throws `Cannot read properties of
|
||
undefined`. Use optional chaining (`tenant.provisioningErrors?.[k]`).
|
||
- Nitro can throw `Could not load /app/server/api/...` if Vite picks
|
||
up a new file mid-build. Container restart clears it.
|
||
|
||
### O.6 · Partner management (real backend) ✓
|
||
|
||
- [x] `pages/partners/index.vue` — list with name/domain/status/customers/margin
|
||
- [x] `pages/partners/[slug].vue` — detail panel with contract card, contact
|
||
card, customers table, attach modal, terminate danger card
|
||
- [x] "Create partner" modal — POST /partners, navigates to detail on success
|
||
- [x] Attach / detach tenant to partner (PATCH on tenant.partnerId, with $unset
|
||
for detach so the field disappears cleanly)
|
||
- [x] `services/platform-api/src/schemas/tenant.schema.ts` — added the
|
||
`partnerId` Prop. It was missing, which is why early PATCH attempts
|
||
returned 200 but Mongoose silently dropped the field. Smoke-tested with
|
||
acme ⇄ nordicmsp and a throwaway temp-msp partner (created + terminated).
|
||
- MRR aggregation deferred until Subscription gains real pricing (see
|
||
follow-ups). For now `customers` is just a count of attached tenants.
|
||
|
||
### O.7 · Visual-only screens (mock fixtures) ✓
|
||
|
||
- [x] `data/fixtures.ts` — typed mock fixtures (SERVICES, INCIDENT, FLAGS,
|
||
OP_AUDIT). Tenant/partner/user extras are NOT mocked — those screens
|
||
pull from the real backend.
|
||
- [x] `pages/index.vue` — Overview dashboard: KPIs from real tenants/partners
|
||
/users + status meter + recent + needs-follow-up tables, with mock
|
||
activity stream + incident banner overlay.
|
||
- [x] `pages/operator-team.vue` — real `GET /users` filtered to
|
||
`platformAdmin === true`.
|
||
- [x] `pages/users.vue` — real `GET /users` with All / Admins / Inactive
|
||
views and search.
|
||
- [x] `pages/infrastructure.vue` — service health (mock SERVICES);
|
||
docker healthcheck + Prometheus wiring is a follow-up.
|
||
- [x] `pages/flags.vue` — feature flags (mock FLAGS).
|
||
- [x] `pages/audit.vue` — cross-tenant audit (mock OP_AUDIT) with search.
|
||
- [x] `pages/support.vue` — `OpPlaceholder` stub.
|
||
- [x] `pages/billing.vue` — `OpPlaceholder` stub.
|
||
- [x] `pages/reports.vue` — `OpPlaceholder` stub.
|
||
- [x] `pages/settings.vue` — `OpPlaceholder` stub.
|
||
- [x] Shared bits added: `components/Stat.vue`, `components/MetricCell.vue`,
|
||
`components/OpPlaceholder.vue`, `server/api/users/index.get.ts`,
|
||
`types/user.ts`.
|
||
|
||
### O.8 · Interactions ✓
|
||
|
||
- [x] `CommandPalette.vue` + `useCommandPalette` — ⌘K opens, searches real
|
||
tenants/partners + mock flags + nav items + actions. Arrow keys + Enter
|
||
navigate, Escape/backdrop close.
|
||
- [x] `ImpersonationModal.vue` + `useImpersonation` — confirm modal with
|
||
reason field, opens from tenant detail (`Impersonate` action) and from
|
||
the palette (`Impersonate user…` action). Stub — no real OBO token is
|
||
minted. Follow-up to wire OAuth Token Exchange remains.
|
||
- [x] `ImpersonationBanner.vue` — full-width red banner at the top of the
|
||
shell, persists until `Exit impersonation` is clicked.
|
||
- [x] `IncidentModal.vue` + `useIncidentModal` — opens from the Overview and
|
||
Infrastructure incident banners, renders mock `INCIDENT` data
|
||
(metrics + timeline + draft composer).
|
||
- [x] `TweaksPanel.vue` + `useTweaks` — floating bottom-right panel. Theme
|
||
(dark/light), density (comfy/compact), env badge (prod/staging/dev).
|
||
Choices persist to localStorage, apply via `[data-theme]` / `[data-density]`
|
||
overrides in tokens.css.
|
||
- [x] Layout wires ⌘K + ⌘[ globally. Topbar reads env from `useTweaks`.
|
||
|
||
### O.9 · Verification ✓
|
||
|
||
All smokes ran end-to-end on 2026-05-24 against the live local stack.
|
||
|
||
- [x] Signed in to `operator.dezky.local` as akadmin via the
|
||
`dezky-operator` OAuth client.
|
||
- [x] JWT audience confirmed via `GET /api/_verify-token`:
|
||
```
|
||
iss: https://auth.dezky.local/application/o/dezky-operator/
|
||
aud: dezky-operator
|
||
sub: bc865e33...
|
||
groups: [authentik Admins, dezky, dezky-platform-admins]
|
||
```
|
||
- [x] Created `verify-msp` Partner via the UI ("New partner" modal) — verified
|
||
in Mongo: `_id: 6a129d6a44c0f44fddda34bf`, `marginPct: 15`.
|
||
- [x] Attached `acme` tenant via the Attach modal on the partner detail page;
|
||
Mongo confirmed `tenants.acme.partnerId == partners.verify-msp._id` and
|
||
the customers count in the UI rose from 0 → 1.
|
||
- [x] Suspended `acme` from the Danger tab — Mongo confirmed
|
||
`tenants.acme.status == 'suspended'`. Resumed it back to `active`
|
||
afterwards so the dev tenant stays usable.
|
||
- [x] Signed in to `app.dezky.local` in a parallel tab; `GET /api/_verify-token`
|
||
there returned `aud: dezky-portal`, `iss: .../dezky-portal/`. Both
|
||
sessions coexist; each app uses its own per-app issuer + audience.
|
||
`GET /api/me` on the portal still returns profile + tenants +
|
||
subscriptions correctly.
|
||
- [x] Follow-up tasks rolled into NEXT-STEPS.md under
|
||
"Follow-ups before operator hits production".
|
||
|
||
### Throwaway artifacts left behind for now
|
||
|
||
These were added during O.9 verification and can be ripped out when the
|
||
relevant production gates land:
|
||
|
||
- `apps/operator/server/api/_verify-token.get.ts` — JWT claim echo
|
||
- `apps/portal/server/api/_verify-token.get.ts` — JWT claim echo
|
||
- `apps/operator/server/api/operator-smoke-test.post.ts` — O.3-era audience check
|
||
- `apps/portal/server/api/partners/index.post.ts` — O.2-era audience-deny verifier
|