feat(operator): tenant list + 7-tab detail with real lifecycle (O.5)

Operator can now manage tenants end-to-end from the UI:

  - pages/tenants/index.vue — list with status/plan/domains/created/
    provisioning-state columns, search by slug or name, status chips
    with live counts (all/active/pending/suspended), click-through
    to detail
  - pages/tenants/[slug].vue — 7-tab detail (Overview, Users, Resources,
    Billing, Audit, Support, Danger zone)
  - 3 tabs hit real backends: Overview (identity + billing fields),
    Users (lazy-loaded via new GET /tenants/:slug/users endpoint),
    Resources (live provisioning state per integration + Reconcile button)
  - 3 tabs render mock fixtures with warn-tone "mock" badges: Billing
    (Stripe placeholder), Audit (sample log lines), Support (placeholder
    pending the ticket queue work)
  - Danger zone: 3 real-backend cards (Suspend / Resume / Soft-delete),
    each gated by a ConfirmDialog modal. Verified live — clicked
    Suspend on acme, status flipped to 'suspended' in Mongo, then
    Resumed back to 'active'

platform-api additions:
  - GET /tenants/:slug/users returns users with this tenant in their
    tenantIds, sorted by last login. Same authorization rule as the
    existing /tenants/:slug — platform admins always pass,
    non-admins must be a member of the tenant
  - tenants.module imports User schema for the new lookup

New components (apps/operator/components/):
  - Tabs.vue — horizontal strip with optional per-tab counts, v-model
  - ConfirmDialog.vue — Teleport-to-body modal, Escape/backdrop close,
    danger/primary tone for the confirm button

Server proxy infrastructure (apps/operator/server/):
  - utils/platform-api.ts — single helper encapsulating
    access-token-from-session + bearer-forward + error normalization.
    Every operator proxy route is now a one-liner against this helper
  - api/tenants/index.get.ts, [slug]/{index.get,index.patch,index.delete,
    users.get,suspend.post,resume.post,reconcile.post}.ts

Two real bugs found and fixed during the smoke test:

  - Mongoose subdocument `_id` leaks into JSON when iterating
    tenant.provisioningStatus. Switched to an explicit
    `['authentik', 'stalwart', 'ocis']` whitelist in both v-fors
  - Documents created before provisioningErrors was added (like the
    acme tenant) don't have the field at all in JSON. Use optional
    chaining (`tenant.provisioningErrors?.[k]`) instead of bracket
    access. Without it: 'Cannot read properties of undefined (reading
    "authentik")' during the Resources tab render
This commit is contained in:
Ronni Baslund
2026-05-24 07:44:23 +02:00
parent 8e6f73a921
commit 8e81730372
18 changed files with 1107 additions and 14 deletions
@@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
import { ProvisioningService } from './provisioning.service.js'
@@ -10,9 +11,18 @@ import { ProvisioningService } from './provisioning.service.js'
export class TenantsService {
constructor(
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
private readonly provisioning: ProvisioningService,
) {}
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
const tenant = await this.findOneBySlug(slug)
return this.userModel
.find({ tenantIds: tenant._id })
.sort({ lastLoginAt: -1, createdAt: -1 })
.exec()
}
async create(dto: CreateTenantDto): Promise<TenantDocument> {
const exists = await this.tenantModel.exists({ slug: dto.slug })
if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`)