feat: partner enrichment, mutations, settings & branding + operator quick-wins

Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation.

Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save.

Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
This commit is contained in:
Ronni Baslund
2026-05-30 08:03:07 +02:00
parent a51dc9a732
commit 89691626f4
33 changed files with 1753 additions and 198 deletions
@@ -1,9 +1,13 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
@@ -15,10 +19,18 @@ 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 { PartnersService } from '../partners/partners.service.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 { InvitePartnerUserDto } from '../users/dto/invite-partner-user.dto.js'
import { UsersService } from '../users/users.service.js'
import { CreateReportDto } from './dto/create-report.dto.js'
import { PartnerBrandingDto } from './dto/partner-branding.dto.js'
import { PartnerSettingsDto } from './dto/partner-settings.dto.js'
import { PartnerUpdateTenantDto } from './dto/partner-update-tenant.dto.js'
import { PartnerBrandingService } from './partner-branding.service.js'
import { PartnerReportsService } from './partner-reports.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
@@ -34,6 +46,9 @@ export class PartnerMeController {
constructor(
private readonly users: UsersService,
private readonly tenants: TenantsService,
private readonly partners: PartnersService,
private readonly branding: PartnerBrandingService,
private readonly reports: PartnerReportsService,
private readonly actor: ActorService,
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
) {}
@@ -47,7 +62,45 @@ export class PartnerMeController {
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.users.listPartnerUsers(actor.partnerId)
return this.users.listPartnerUsersEnriched(actor.partnerId)
}
// Self-service partner-staff invite. Counterpart to the operator-only
// POST /partners/:slug/users — scoped to the caller's own partner.
@Post('users')
async inviteUser(
@Body() dto: InvitePartnerUserDto,
@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 partner = await this.partnerModel.findById(actor.partnerId, { slug: 1 }).exec()
if (!partner) {
throw new ForbiddenException('Partner record missing')
}
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
return this.users.invitePartnerUser(dto, { _id: actor.partnerId, slug: partner.slug }, auditActor)
}
// Remove a teammate from the partner organization.
@Delete('users/:subject')
async removeUser(
@Param('subject') subject: string,
@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')
}
if (subject === jwt.sub) {
throw new ForbiddenException('You cannot remove your own account')
}
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
return this.users.removePartnerUser(subject, actor.partnerId, auditActor)
}
// Tenants (customers) attached to the partner.
@@ -105,6 +158,51 @@ export class PartnerMeController {
return { tenant, adminInvite }
}
// Edit a customer the partner owns (name, industry, brand colour, domains,
// seats). Ownership is enforced in TenantsService.partnerUpdate.
@Patch('tenants/:slug')
async updateTenant(
@Param('slug') slug: string,
@Body() dto: PartnerUpdateTenantDto,
@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) }
return this.tenants.partnerUpdate(slug, actor.partnerId, dto, auditActor)
}
@Post('tenants/:slug/suspend')
async suspendTenant(
@Param('slug') slug: string,
@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) }
return this.tenants.partnerSetStatus(slug, actor.partnerId, 'suspended', auditActor)
}
@Post('tenants/:slug/resume')
async resumeTenant(
@Param('slug') slug: string,
@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) }
return this.tenants.partnerSetStatus(slug, actor.partnerId, 'active', auditActor)
}
// Monthly Recurring Revenue across the partner's customers — grouped by
// currency since subs can be billed in DKK / EUR / USD independently.
@Get('mrr')
@@ -138,4 +236,102 @@ export class PartnerMeController {
before: before ? new Date(before) : undefined,
})
}
// Partner settings — profile, notification prefs, agreement (read-only),
// documents (read-only), plus marginPct/contactInfo/billingInfo for display.
@Get('settings')
async getSettings(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.partners.getSettings(actor.partnerId)
}
@Patch('settings')
async updateSettings(
@Body() dto: PartnerSettingsDto,
@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) }
return this.partners.updateSettings(actor.partnerId, dto, auditActor)
}
// Whitelabel branding — identity, customer defaults, email templates.
@Get('branding')
async getBranding(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.branding.get(actor.partnerId)
}
@Put('branding')
async putBranding(
@Body() dto: PartnerBrandingDto,
@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) }
return this.branding.put(actor.partnerId, dto, auditActor)
}
// Live analytics for the reports page (health cohorts, revenue by plan, top
// customers, signup/churn cohorts). Computed, not stored.
@Get('reports')
async getReports(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.users.partnerReports(actor.partnerId)
}
// Saved/custom report definitions.
@Get('reports/saved')
async listSavedReports(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.reports.list(actor.partnerId)
}
@Post('reports/saved')
async createSavedReport(
@Body() dto: CreateReportDto,
@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) }
return this.reports.create(actor.partnerId, dto, auditActor)
}
@Delete('reports/saved/:id')
async deleteSavedReport(
@Param('id') id: string,
@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) }
return this.reports.remove(id, actor.partnerId, auditActor)
}
}