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:
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuthModule } from '../auth/auth.module.js'
|
||||
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
|
||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||
import { UsersModule } from '../users/users.module.js'
|
||||
import { PartnerMeController } from './partner-me.controller.js'
|
||||
|
||||
// Self-service portal surface. Composes UsersService (partner-scoped reads,
|
||||
// invitePartnerUser, inviteTenantAdmin, partnerMrr, partnerActivity) and
|
||||
// TenantsService (create) behind clean /me/partner/* URLs. No service of
|
||||
// its own — the controllers are thin façades.
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
TenantsModule,
|
||||
MongooseModule.forFeature([{ name: Partner.name, schema: PartnerSchema }]),
|
||||
],
|
||||
controllers: [PartnerMeController],
|
||||
})
|
||||
export class MeModule {}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user