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
@@ -2,14 +2,26 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestj
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import type { AuditEventDocument } from '../schemas/audit-event.schema.js'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Price, PriceDocument } from '../schemas/price.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { CreateUserDto } from './dto/create-user.dto.js'
import type { InviteOperatorDto } from './dto/invite-operator.dto.js'
import type { InvitePartnerUserDto } from './dto/invite-partner-user.dto.js'
import type { UpdateUserDto } from './dto/update-user.dto.js'
// Authentik group every partner-staff invite gets added to. We use ONE
// group across all partners (instead of `dezky-partner-{slug}` per partner)
// because partner scoping is enforced server-side via User.partnerId — the
// group claim just marks "this user is partner staff, route them to the
// partner portal." Simpler than reconciling groups on partner rename.
const PARTNER_STAFF_GROUP = 'dezky-partner-staff'
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name)
@@ -18,10 +30,16 @@ export class UsersService {
// created once during Authentik bootstrap and never moves; no need to look
// it up every invite.
private platformAdminGroupId: string | null = null
// Same caching for the partner-staff group. Created lazily on first
// invitePartnerUser call via ensureGroup (idempotent).
private partnerStaffGroupId: string | null = null
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>,
private readonly audit: AuditService,
private readonly authentik: AuthentikClient,
config: ConfigService,
@@ -90,6 +108,41 @@ export class UsersService {
// Called on every authenticated request from /users/me. The JWT's groups claim
// is treated as a hint for first-time membership sync — the DB is the source of
// truth for all subsequent authorization decisions.
// What /users/me returns — the user doc plus the partner object when
// User.partnerId is set. Frontends use the presence of `partner` to decide
// whether to render the partner-admin surface vs. the end-user surface.
async meWithPartner(payload: {
subject: string
email: string
name: string
tenantSlugs: string[]
platformAdmin: boolean
}): Promise<UserDocument & { partner?: { _id: string; slug: string; name: string; status: string } }> {
const user = await this.upsertFromAuthentik(payload)
if (!user.partnerId) {
return user as UserDocument & { partner?: never }
}
const partner = await this.partnerModel
.findById(user.partnerId, { slug: 1, name: 1, status: 1 })
.exec()
if (!partner) {
// Partner was deleted out from under the user. Don't fail the whole
// /me call — just omit the partner field; the frontend will treat
// them as a regular end-user.
return user as UserDocument & { partner?: never }
}
const userObj = user.toObject() as UserDocument & {
partner?: { _id: string; slug: string; name: string; status: string }
}
userObj.partner = {
_id: String(partner._id),
slug: partner.slug,
name: partner.name,
status: partner.status,
}
return userObj
}
async upsertFromAuthentik(payload: {
subject: string
email: string
@@ -217,6 +270,391 @@ export class UsersService {
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// Invite a user that works at a partner organization. Same shape as
// inviteOperator but adds the dezky-partner-staff group, sets
// User.partnerId, and records a partner-scoped audit event. The caller
// (PartnersController) already resolved the slug → partner, so we receive
// the partnerId directly.
async invitePartnerUser(
dto: InvitePartnerUserDto,
partner: { _id: Types.ObjectId; slug: string },
actor?: AuditActor,
): Promise<{
subject: string
userId: string
// True if we attached an existing Authentik user instead of creating one.
// When attached, link/tempPassword are omitted (the user already has a
// password) and the UI shows a simpler success view.
attached?: boolean
link?: string
tempPassword?: string
}> {
const groupPk = await this.resolvePartnerStaffGroupId()
// ── Attach path: user already exists in Authentik ────────────────────
// Common when:
// - Operator promotes an existing platform admin to also work for a
// partner (e.g. internal staff cross-referenced as a partner contact).
// - User was created via a different invite path (operator team) and
// should now also be visible under a partner.
// Refuse only if their local User doc already points at a DIFFERENT
// partner — silently moving them would erase the prior relationship.
const existing = await this.authentik.findUserByEmail(dto.email)
if (existing) {
const localUser = await this.userModel.findOne({ authentikSubjectId: existing.uid }).exec()
if (
localUser?.partnerId &&
String(localUser.partnerId) !== String(partner._id)
) {
throw new ConflictException(
`User ${dto.email} already belongs to partner ${String(localUser.partnerId)} — detach them from that partner first.`,
)
}
await this.authentik.addUserToGroup(existing.pk, groupPk)
// Upsert local User. Existing docs get partnerId set; missing docs
// (Authentik-only users like akadmin pre-/users/me) are created so the
// partner team list shows them immediately instead of waiting for first
// login to materialize the doc.
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: existing.uid },
{
$set: {
email: existing.email,
// Don't clobber the local name if we have one (e.g. they
// already logged in and set it from the JWT); only seed on insert.
partnerId: partner._id,
},
$setOnInsert: {
name: existing.name || dto.name,
role: 'member',
active: true,
tenantIds: [],
platformAdmin: false,
},
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
void this.audit.record(
{
action: 'partner.user_attached',
resourceType: 'user',
resourceId: existing.uid,
resourceName: existing.email,
partnerSlug: partner.slug,
metadata: { name: existing.name || dto.name, role: 'partner-staff' },
},
actor,
)
return { subject: existing.uid, userId: String(existing.pk), attached: true }
}
// ── Create path: brand-new user in Authentik ─────────────────────────
const created = await this.authentik.createUser({
username: dto.email,
email: dto.email,
name: dto.name,
groupPks: [groupPk],
attributes: {
partnerSlug: partner.slug,
invitedBy: actor?.email,
invitedAt: new Date().toISOString(),
},
})
// Pre-create the local User doc with partnerId so the partner team list
// reflects the invite immediately. On first login, /users/me upserts
// and reconciles email/name/lastLoginAt from the JWT (partnerId is
// preserved — see upsertFromAuthentik).
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: {
email: dto.email,
name: dto.name,
partnerId: partner._id,
},
$setOnInsert: { role: 'member', active: true, tenantIds: [], platformAdmin: false },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
let link: string | undefined
let tempPassword: string | undefined
link = await this.authentik.recoveryLink(created.pk)
if (!link) {
tempPassword = generateTempPassword()
await this.authentik.setInitialPassword(created.pk, tempPassword)
await this.authentik.markPasswordExpired(created.pk)
}
void this.audit.record(
{
action: 'partner.user_invited',
resourceType: 'user',
resourceId: created.uid,
resourceName: dto.email,
partnerSlug: partner.slug,
metadata: {
role: 'partner-staff',
name: dto.name,
handoff: link ? 'recovery-link' : 'temp-password',
},
},
actor,
)
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// List users belonging to a partner. Called by the operator partner-detail
// page's Team section.
async listPartnerUsers(partnerId: Types.ObjectId): Promise<UserDocument[]> {
return this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
}
// List tenants attached to a partner. Used by the partner-portal's
// /partner/customers page (via /users/me/partner/tenants) and could be
// reused for operator surfaces that want partner-scoped tenant queries.
// Each tenant carries a userCount (admins + members) so the seat "used"
// column can render N/M without a second round-trip from the client.
async listPartnerTenants(
partnerId: Types.ObjectId,
): Promise<Array<TenantDocument & { userCount: number; newUserCount30d: number }>> {
const tenants = await this.tenantModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
if (tenants.length === 0) return []
const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
// Single aggregation across all of the partner's tenants. We compute
// both the total active user count and the subset created in the last
// 30 days using $cond — one pass over the collection regardless of
// how many tenants the partner has.
const counts = await this.userModel.aggregate<{
_id: Types.ObjectId
n: number
new30d: number
}>([
{ $match: { tenantIds: { $in: tenants.map((t) => t._id) }, active: true } },
{ $unwind: '$tenantIds' },
{ $match: { tenantIds: { $in: tenants.map((t) => t._id) } } },
{
$group: {
_id: '$tenantIds',
n: { $sum: 1 },
new30d: { $sum: { $cond: [{ $gte: ['$createdAt', since30d] }, 1, 0] } },
},
},
])
const countMap = new Map(counts.map((c) => [String(c._id), c]))
return tenants.map((t) => {
const obj = t.toObject() as TenantDocument & {
userCount: number
newUserCount30d: number
}
const c = countMap.get(String(t._id))
obj.userCount = c?.n ?? 0
obj.newUserCount30d = c?.new30d ?? 0
return obj
})
}
// Create (or attach) the first admin user for a freshly-provisioned
// tenant. Same shape as invitePartnerUser but adds the user to the
// tenant's Authentik group (created during provisioning) instead of
// dezky-partner-staff, sets User.tenantIds to include this tenant, and
// audits with tenantSlug.
//
// Failures are surfaced to the caller (TenantsController) rather than
// swallowed — the wizard wants to show "admin invite failed: ..." in the
// done state so the operator can retry rather than silently shipping a
// tenant with no admin.
async inviteTenantAdmin(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
dto: { name: string; email: string },
actor?: AuditActor,
): Promise<{
subject: string
userId: string
attached?: boolean
link?: string
tempPassword?: string
}> {
if (!tenant.authentikGroupId) {
throw new Error(
`Tenant ${tenant.slug} has no authentikGroupId — provisioning didn't complete. Retry /tenants/${tenant.slug}/reconcile and re-invite.`,
)
}
// ── Attach existing Authentik user ─────────────────────────────────
const existing = await this.authentik.findUserByEmail(dto.email)
if (existing) {
await this.authentik.addUserToGroup(existing.pk, tenant.authentikGroupId)
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: existing.uid },
{
$set: { email: existing.email },
$setOnInsert: {
name: existing.name || dto.name,
role: 'admin',
active: true,
platformAdmin: false,
},
$addToSet: { tenantIds: tenant._id },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
void this.audit.record(
{
action: 'tenant.admin_attached',
resourceType: 'user',
resourceId: existing.uid,
resourceName: existing.email,
tenantSlug: tenant.slug,
metadata: { name: existing.name || dto.name, role: 'admin' },
},
actor,
)
return { subject: existing.uid, userId: String(existing.pk), attached: true }
}
// ── Create new Authentik user ──────────────────────────────────────
const created = await this.authentik.createUser({
username: dto.email,
email: dto.email,
name: dto.name,
groupPks: [tenant.authentikGroupId],
attributes: {
tenantSlug: tenant.slug,
invitedBy: actor?.email,
invitedAt: new Date().toISOString(),
},
})
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: { email: dto.email, name: dto.name },
$setOnInsert: { role: 'admin', active: true, platformAdmin: false },
$addToSet: { tenantIds: tenant._id },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
let link: string | undefined
let tempPassword: string | undefined
link = await this.authentik.recoveryLink(created.pk)
if (!link) {
tempPassword = generateTempPassword()
await this.authentik.setInitialPassword(created.pk, tempPassword)
await this.authentik.markPasswordExpired(created.pk)
}
void this.audit.record(
{
action: 'tenant.admin_invited',
resourceType: 'user',
resourceId: created.uid,
resourceName: dto.email,
tenantSlug: tenant.slug,
metadata: {
name: dto.name,
role: 'admin',
handoff: link ? 'recovery-link' : 'temp-password',
},
},
actor,
)
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// Recent audit events scoped to a partner — events whose partnerSlug
// matches OR whose tenantSlug belongs to one of the partner's tenants.
// Used by the partner dashboard's Activity card.
async partnerActivity(
partnerId: Types.ObjectId,
partnerSlug: string,
opts: { limit?: number; before?: Date } = {},
): Promise<AuditEventDocument[]> {
const tenants = await this.tenantModel.find({ partnerId }, { slug: 1 }).exec()
return this.audit.listForPartner({
partnerSlug,
tenantSlugs: tenants.map((t) => t.slug),
limit: opts.limit,
before: opts.before,
})
}
// MRR aggregation for a partner, grouped by currency. Each subscription
// contributes to the bucket for its currency — no FX conversion, since
// the partner gets paid in whatever currency the customer was billed in.
//
// Per-seat amount is read from Subscription.perSeatAmount (the snapshot
// taken at provision time) instead of the live Price doc, so historical
// MRR is stable even if the operator edits the catalog later.
async partnerMrr(partnerId: Types.ObjectId): Promise<{
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
breakdown: Array<{
tenantId: string
tenantSlug: string
tenantName: string
plan: 'mvp' | 'pro' | 'enterprise'
cycle: 'monthly' | 'quarterly' | 'yearly'
currency: 'DKK' | 'EUR' | 'USD'
seats: number
monthlyMinor: number
custom: boolean // true when the sub has no priced amount (Enterprise / pre-catalog)
}>
}> {
const tenants = await this.tenantModel.find({ partnerId }).exec()
if (tenants.length === 0) {
return { totals: [], breakdown: [] }
}
const tenantIds = tenants.map((t) => t._id)
const subs = await this.subModel.find({ tenantId: { $in: tenantIds }, status: 'active' }).exec()
const tenantById = new Map(tenants.map((t) => [String(t._id), t]))
const breakdown = subs.map((s) => {
const tenant = tenantById.get(String(s.tenantId))!
const monthlyMinor = normalizeToMonthly(s.perSeatAmount * s.seats, s.cycle)
return {
tenantId: String(tenant._id),
tenantSlug: tenant.slug,
tenantName: tenant.name,
plan: s.plan,
cycle: s.cycle,
currency: s.currency,
seats: s.seats,
monthlyMinor,
custom: s.perSeatAmount === 0,
}
})
// Group by currency. Use a Map to preserve insertion-on-first-seen
// ordering — but emit totals in a stable order regardless: DKK, EUR, USD.
const byCurrency = new Map<string, number>()
for (const row of breakdown) {
byCurrency.set(row.currency, (byCurrency.get(row.currency) ?? 0) + row.monthlyMinor)
}
const ORDER: Array<'DKK' | 'EUR' | 'USD'> = ['DKK', 'EUR', 'USD']
const totals = ORDER.filter((c) => byCurrency.has(c)).map((c) => ({
currency: c,
monthlyMinor: byCurrency.get(c)!,
}))
return { totals, breakdown }
}
// Resolve + cache the dezky-platform-admins group ID. The group is created
// by Authentik bootstrap so it's reliably present; ensureGroup is
// idempotent so the worst case is a no-op extra API call on cold start.
@@ -228,6 +666,18 @@ export class UsersService {
this.platformAdminGroupId = group.pk
return group.pk
}
// Same caching pattern for the partner-staff group. Created lazily the
// first time a partner invite runs — by then Authentik is past bootstrap
// and ensureGroup will either find or create it.
private async resolvePartnerStaffGroupId(): Promise<string> {
if (this.partnerStaffGroupId) return this.partnerStaffGroupId
const group = await this.authentik.ensureGroup(PARTNER_STAFF_GROUP, {
role: 'partner-staff',
})
this.partnerStaffGroupId = group.pk
return group.pk
}
}
// Generates a 16-character random password with mixed character classes.
@@ -257,3 +707,13 @@ function generateTempPassword(): string {
}
return out.join('')
}
// Convert a per-cycle subscription total (in minor units) to its monthly
// equivalent. Used by MRR aggregation. Integer math throughout — final
// rounding happens once at the partner-total level so per-row drift can't
// accumulate visibly.
function normalizeToMonthly(perCycleMinor: number, cycle: 'monthly' | 'quarterly' | 'yearly'): number {
if (cycle === 'monthly') return perCycleMinor
if (cycle === 'quarterly') return Math.round(perCycleMinor / 3)
return Math.round(perCycleMinor / 12)
}