89691626f4
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.
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
ForbiddenException,
|
|
Get,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Put,
|
|
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 { 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
|
|
// 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 partners: PartnersService,
|
|
private readonly branding: PartnerBrandingService,
|
|
private readonly reports: PartnerReportsService,
|
|
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.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.
|
|
@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 }
|
|
}
|
|
|
|
// 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')
|
|
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,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|