feat: portal redesign, pricing catalog, partner-staff invites

- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
This commit is contained in:
Ronni Baslund
2026-05-28 20:00:33 +02:00
parent be430179d9
commit 0bd4e5498e
144 changed files with 22110 additions and 209 deletions
@@ -0,0 +1,141 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import type { Model } from 'mongoose'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { CreateTenantDto } from '../tenants/dto/create-tenant.dto.js'
import { TenantsService } from '../tenants/tenants.service.js'
import { UsersService } from '../users/users.service.js'
// Self-service endpoints for the partner portal. Everything here scopes to
// the caller's resolved User.partnerId — no slug in any URL, no operator
// guard. A portal-aud JWT is sufficient; partner-staff is enforced per
// handler via the actor.partnerId check.
//
// Identity endpoints (GET /users/me, etc.) intentionally stay on
// UsersController — those are about "who am I", whereas everything here is
// about "what does my partner own".
@Controller('me/partner')
@UseGuards(JwtAuthGuard)
export class PartnerMeController {
constructor(
private readonly users: UsersService,
private readonly tenants: TenantsService,
private readonly actor: ActorService,
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
) {}
// The OTHER people at the caller's partner organization. Distinct from
// GET /partners/:slug/users (operator only): scoped via the actor's
// User.partnerId so a portal-aud JWT works.
@Get('users')
async listUsers(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.users.listPartnerUsers(actor.partnerId)
}
// Tenants (customers) attached to the partner.
@Get('tenants')
async listTenants(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.users.listPartnerTenants(actor.partnerId)
}
// Self-service tenant create. Counterpart to operator POST /tenants.
// Forces partnerId from actor.partnerId so a partner can never create
// a tenant under a different partner, even if their payload says so.
// If adminName + adminEmail are present, fires inviteTenantAdmin after
// tenant provisioning. Admin failures don't roll back the tenant — the
// response includes an `adminInvite` field with credentials or an error.
@Post('tenants')
async createTenant(
@Body() dto: CreateTenantDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
const auditActor = {
userId: String(actor._id),
email: actor.email,
ip: clientIp(req),
}
const safeDto = { ...dto, partnerId: actor.partnerId } as CreateTenantDto & {
partnerId: typeof actor.partnerId
}
const tenant = await this.tenants.create(safeDto, auditActor)
let adminInvite:
| { subject: string; userId: string; attached?: boolean; link?: string; tempPassword?: string }
| { error: string }
| undefined
if (dto.adminName && dto.adminEmail) {
try {
adminInvite = await this.users.inviteTenantAdmin(
{ _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId },
{ name: dto.adminName, email: dto.adminEmail },
auditActor,
)
} catch (err) {
adminInvite = { error: err instanceof Error ? err.message : String(err) }
}
}
return { tenant, adminInvite }
}
// Monthly Recurring Revenue across the partner's customers — grouped by
// currency since subs can be billed in DKK / EUR / USD independently.
@Get('mrr')
async mrr(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.users.partnerMrr(actor.partnerId)
}
// Recent audit events across the partner's portfolio. Used by the
// dashboard's Activity card and the /partner/audit page. Pagination via
// ?before=<iso> + ?limit=N.
@Get('activity')
async activity(
@CurrentUser() jwt: AuthentikJwtPayload,
@Query('limit') limit?: string,
@Query('before') before?: string,
) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
const partner = await this.partnerModel.findById(actor.partnerId, { slug: 1 }).exec()
if (!partner) {
throw new ForbiddenException('Partner record missing')
}
return this.users.partnerActivity(actor.partnerId, partner.slug, {
limit: limit ? Number(limit) : undefined,
before: before ? new Date(before) : undefined,
})
}
}