diff --git a/apps/operator/components/ConfirmDialog.vue b/apps/operator/components/ConfirmDialog.vue new file mode 100644 index 0000000..b55c2e9 --- /dev/null +++ b/apps/operator/components/ConfirmDialog.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/apps/operator/components/Tabs.vue b/apps/operator/components/Tabs.vue new file mode 100644 index 0000000..5796ac0 --- /dev/null +++ b/apps/operator/components/Tabs.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/apps/operator/pages/tenants/[slug].vue b/apps/operator/pages/tenants/[slug].vue new file mode 100644 index 0000000..62c4dc8 --- /dev/null +++ b/apps/operator/pages/tenants/[slug].vue @@ -0,0 +1,455 @@ + + + + + diff --git a/apps/operator/pages/tenants/index.vue b/apps/operator/pages/tenants/index.vue new file mode 100644 index 0000000..bdf04d7 --- /dev/null +++ b/apps/operator/pages/tenants/index.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/apps/operator/server/api/tenants/[slug]/index.delete.ts b/apps/operator/server/api/tenants/[slug]/index.delete.ts new file mode 100644 index 0000000..f5e2e72 --- /dev/null +++ b/apps/operator/server/api/tenants/[slug]/index.delete.ts @@ -0,0 +1,7 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + await platformApi(event, `/tenants/${slug}`, { method: 'DELETE' }) + return { ok: true } +}) diff --git a/apps/operator/server/api/tenants/[slug]/index.get.ts b/apps/operator/server/api/tenants/[slug]/index.get.ts new file mode 100644 index 0000000..ac3eda6 --- /dev/null +++ b/apps/operator/server/api/tenants/[slug]/index.get.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + return platformApi(event, `/tenants/${slug}`) +}) diff --git a/apps/operator/server/api/tenants/[slug]/index.patch.ts b/apps/operator/server/api/tenants/[slug]/index.patch.ts new file mode 100644 index 0000000..e4ac22d --- /dev/null +++ b/apps/operator/server/api/tenants/[slug]/index.patch.ts @@ -0,0 +1,7 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + const body = await readBody(event) + return platformApi(event, `/tenants/${slug}`, { method: 'PATCH', body }) +}) diff --git a/apps/operator/server/api/tenants/[slug]/reconcile.post.ts b/apps/operator/server/api/tenants/[slug]/reconcile.post.ts new file mode 100644 index 0000000..259d533 --- /dev/null +++ b/apps/operator/server/api/tenants/[slug]/reconcile.post.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + return platformApi(event, `/tenants/${slug}/reconcile`, { method: 'POST' }) +}) diff --git a/apps/operator/server/api/tenants/[slug]/resume.post.ts b/apps/operator/server/api/tenants/[slug]/resume.post.ts new file mode 100644 index 0000000..66e7788 --- /dev/null +++ b/apps/operator/server/api/tenants/[slug]/resume.post.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + return platformApi(event, `/tenants/${slug}/resume`, { method: 'POST' }) +}) diff --git a/apps/operator/server/api/tenants/[slug]/suspend.post.ts b/apps/operator/server/api/tenants/[slug]/suspend.post.ts new file mode 100644 index 0000000..55308bf --- /dev/null +++ b/apps/operator/server/api/tenants/[slug]/suspend.post.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + return platformApi(event, `/tenants/${slug}/suspend`, { method: 'POST' }) +}) diff --git a/apps/operator/server/api/tenants/[slug]/users.get.ts b/apps/operator/server/api/tenants/[slug]/users.get.ts new file mode 100644 index 0000000..0814807 --- /dev/null +++ b/apps/operator/server/api/tenants/[slug]/users.get.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + return platformApi(event, `/tenants/${slug}/users`) +}) diff --git a/apps/operator/server/api/tenants/index.get.ts b/apps/operator/server/api/tenants/index.get.ts new file mode 100644 index 0000000..2d77419 --- /dev/null +++ b/apps/operator/server/api/tenants/index.get.ts @@ -0,0 +1,3 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => platformApi(event, '/tenants')) diff --git a/apps/operator/server/utils/platform-api.ts b/apps/operator/server/utils/platform-api.ts new file mode 100644 index 0000000..9df67dc --- /dev/null +++ b/apps/operator/server/utils/platform-api.ts @@ -0,0 +1,32 @@ +// Helper: forward a request to platform-api using the signed-in operator's +// access token. Every operator proxy route uses this — it's the only place +// we touch the encrypted session. + +import type { H3Event } from 'h3' +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +const BASE = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + +export async function platformApi( + event: H3Event, + path: string, + init: { method?: string; body?: unknown; query?: Record } = {}, +): Promise { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) { + throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) + } + + try { + return (await $fetch(`${BASE}${path}`, { + method: (init.method as 'GET' | 'POST' | 'PATCH' | 'DELETE') ?? 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + body: init.body, + query: init.query, + })) as T + } catch (err: unknown) { + const e = err as { statusCode?: number; data?: unknown } + throw createError({ statusCode: e.statusCode ?? 500, data: e.data }) + } +} diff --git a/apps/operator/types/tenant.ts b/apps/operator/types/tenant.ts new file mode 100644 index 0000000..e5d94cb --- /dev/null +++ b/apps/operator/types/tenant.ts @@ -0,0 +1,50 @@ +// Shape returned by /api/tenants — matches Tenant schema on platform-api. +// Kept in a separate file so multiple pages can import without copying. + +export type TenantStatus = 'pending' | 'active' | 'suspended' | 'deleted' +export type TenantPlan = 'mvp' | 'pro' | 'enterprise' +export type IntegrationState = 'pending' | 'ok' | 'error' | 'skipped' + +export interface Tenant { + _id: string + slug: string + name: string + status: TenantStatus + plan: TenantPlan + domains: string[] + partnerId?: string + authentikGroupId?: string + ocisSpaceId?: string + stalwartDomain?: string + billingInfo: { + companyName?: string + vatId?: string + country?: string + contactEmail?: string + } + provisioningStatus: { + authentik: IntegrationState + stalwart: IntegrationState + ocis: IntegrationState + } + provisioningErrors: { + authentik?: string + stalwart?: string + ocis?: string + } + createdAt: string + updatedAt: string +} + +export interface TenantUser { + _id: string + authentikSubjectId: string + email: string + name: string + role: 'owner' | 'admin' | 'member' + active: boolean + platformAdmin: boolean + tenantIds: string[] + lastLoginAt?: string + createdAt: string +} diff --git a/docs/OPERATOR-PLAN.md b/docs/OPERATOR-PLAN.md index d42ea53..90121c2 100644 --- a/docs/OPERATOR-PLAN.md +++ b/docs/OPERATOR-PLAN.md @@ -369,20 +369,51 @@ done in order — earlier ones unblock later ones. 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) +### O.5 · Tenant management (real backend) ✓ -- [ ] `pages/tenants/index.vue` — list with status/plan/seats/MRR columns, - filter by partner and status, search by slug/name -- [ ] `pages/tenants/[slug].vue` — detail view with tabs -- [ ] Tab: **Overview** — header card, key stats, partner link -- [ ] Tab: **Users** — list users via `GET /users?tenantSlug=…` -- [ ] Tab: **Resources** — provisioning status per integration - (Authentik / Stalwart / OCIS), error messages, "Reconcile" button -- [ ] Tab: **Billing** (mock fixtures) -- [ ] Tab: **Audit** (mock fixtures) -- [ ] Tab: **Support** (mock fixtures) -- [ ] Tab: **Danger** — suspend, resume, change plan, soft-delete; real - backend calls, confirmation modals +- [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) diff --git a/services/platform-api/src/tenants/tenants.controller.ts b/services/platform-api/src/tenants/tenants.controller.ts index 0b66fd4..1e48978 100644 --- a/services/platform-api/src/tenants/tenants.controller.ts +++ b/services/platform-api/src/tenants/tenants.controller.ts @@ -53,6 +53,16 @@ export class TenantsController { return tenant } + @Get(':slug/users') + async listUsers(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) { + const actor = await this.actor.resolve(jwt) + const tenant = await this.tenants.findOneBySlug(slug) + if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) { + throw new ForbiddenException(`No access to tenant "${slug}"`) + } + return this.tenants.listUsersForTenant(slug) + } + @Patch(':slug') async update( @Param('slug') slug: string, diff --git a/services/platform-api/src/tenants/tenants.module.ts b/services/platform-api/src/tenants/tenants.module.ts index 0fdc866..e393c07 100644 --- a/services/platform-api/src/tenants/tenants.module.ts +++ b/services/platform-api/src/tenants/tenants.module.ts @@ -3,13 +3,17 @@ import { MongooseModule } from '@nestjs/mongoose' import { AuthModule } from '../auth/auth.module.js' import { IntegrationsModule } from '../integrations/integrations.module.js' import { Tenant, TenantSchema } from '../schemas/tenant.schema.js' +import { User, UserSchema } from '../schemas/user.schema.js' import { ProvisioningService } from './provisioning.service.js' import { TenantsController } from './tenants.controller.js' import { TenantsService } from './tenants.service.js' @Module({ imports: [ - MongooseModule.forFeature([{ name: Tenant.name, schema: TenantSchema }]), + MongooseModule.forFeature([ + { name: Tenant.name, schema: TenantSchema }, + { name: User.name, schema: UserSchema }, + ]), AuthModule, IntegrationsModule, ], diff --git a/services/platform-api/src/tenants/tenants.service.ts b/services/platform-api/src/tenants/tenants.service.ts index 9e9c218..9c4f43f 100644 --- a/services/platform-api/src/tenants/tenants.service.ts +++ b/services/platform-api/src/tenants/tenants.service.ts @@ -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, + @InjectModel(User.name) private readonly userModel: Model, private readonly provisioning: ProvisioningService, ) {} + async listUsersForTenant(slug: string): Promise { + const tenant = await this.findOneBySlug(slug) + return this.userModel + .find({ tenantIds: tenant._id }) + .sort({ lastLoginAt: -1, createdAt: -1 }) + .exec() + } + async create(dto: CreateTenantDto): Promise { const exists = await this.tenantModel.exists({ slug: dto.slug }) if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`)