feat(portal): customer-admin surface on real data + Stripe billing + session resilience
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.
Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.
Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.
Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
with YIQ auto-contrast (readableOn util).
Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
drag-select-to-close).
This commit is contained in:
@@ -10,6 +10,7 @@ import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { UsersModule } from '../users/users.module.js'
|
||||
import { BillingService } from './billing.service.js'
|
||||
import { CustomerBillingController } from './customer-billing.controller.js'
|
||||
import { OperatorBillingController } from './operator-billing.controller.js'
|
||||
import { PartnerBillingController } from './partner-billing.controller.js'
|
||||
import { PayoutWorker } from './payout.worker.js'
|
||||
@@ -29,7 +30,12 @@ import { StripeWebhookController } from './stripe-webhook.controller.js'
|
||||
IntegrationsModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [PartnerBillingController, OperatorBillingController, StripeWebhookController],
|
||||
controllers: [
|
||||
PartnerBillingController,
|
||||
OperatorBillingController,
|
||||
CustomerBillingController,
|
||||
StripeWebhookController,
|
||||
],
|
||||
providers: [BillingService, PayoutWorker],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import type Stripe from 'stripe'
|
||||
import { Model, Types } from 'mongoose'
|
||||
@@ -186,6 +192,102 @@ export class BillingService {
|
||||
return this.tenantInvoices(tenant._id)
|
||||
}
|
||||
|
||||
// Membership-gated variant for the customer-admin portal: only a member of
|
||||
// the tenant (or a platform admin) may read its invoices. Mirrors the
|
||||
// membership check used across the tenant-scoped portal endpoints.
|
||||
async tenantInvoicesForActor(
|
||||
slug: string,
|
||||
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||
): Promise<InvoiceDocument[]> {
|
||||
const tenant = await this.tenantModel.findOne({ slug }, { _id: 1 }).exec()
|
||||
if (!tenant) return []
|
||||
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return this.tenantInvoices(tenant._id)
|
||||
}
|
||||
|
||||
// ── Customer payment method (card on file) ────────────────────────────────
|
||||
|
||||
// Load the tenant + its subscription, enforcing membership. Throws 404 if the
|
||||
// tenant is unknown, 403 if the caller isn't a member.
|
||||
private async resolveTenantSub(
|
||||
slug: string,
|
||||
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||
): Promise<{ tenant: TenantDocument; sub: SubscriptionDocument | null }> {
|
||||
const tenant = await this.tenantModel.findOne({ slug }).exec()
|
||||
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
|
||||
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
const sub = await this.subModel.findOne({ tenantId: tenant._id }).exec()
|
||||
return { tenant, sub }
|
||||
}
|
||||
|
||||
// Ensure the tenant's subscription has a Stripe customer, creating one on
|
||||
// demand (older tenants provisioned before Stripe was enabled lack it).
|
||||
private async ensureStripeCustomer(tenant: TenantDocument, sub: SubscriptionDocument | null): Promise<string> {
|
||||
if (!this.stripe.enabled) throw new ServiceUnavailableException('Billing is not enabled')
|
||||
if (!sub) throw new NotFoundException(`No subscription for tenant "${tenant.slug}"`)
|
||||
if (sub.stripeCustomerId) return sub.stripeCustomerId
|
||||
const customerId = await this.stripe.createCustomer({
|
||||
name: tenant.name,
|
||||
email: tenant.billingInfo?.contactEmail,
|
||||
metadata: { tenantId: String(tenant._id), slug: tenant.slug },
|
||||
})
|
||||
sub.stripeCustomerId = customerId
|
||||
await sub.save()
|
||||
return customerId
|
||||
}
|
||||
|
||||
// Read-only: the card on file, or null. Doesn't create a customer just to read.
|
||||
async getPaymentMethod(
|
||||
slug: string,
|
||||
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||
): Promise<{ brand: string; last4: string; expMonth: number; expYear: number } | null> {
|
||||
if (!this.stripe.enabled) return null
|
||||
const { sub } = await this.resolveTenantSub(slug, actor)
|
||||
if (!sub?.stripeCustomerId) return null
|
||||
return this.stripe.getDefaultCard(sub.stripeCustomerId)
|
||||
}
|
||||
|
||||
// Begin a card-update: returns the SetupIntent secret + publishable key for
|
||||
// the portal to mount Stripe Elements. Creates a Stripe customer if missing.
|
||||
async createPaymentSetupIntent(
|
||||
slug: string,
|
||||
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||
): Promise<{ clientSecret: string; publishableKey: string }> {
|
||||
const { tenant, sub } = await this.resolveTenantSub(slug, actor)
|
||||
const customerId = await this.ensureStripeCustomer(tenant, sub)
|
||||
const clientSecret = await this.stripe.createSetupIntent(customerId)
|
||||
return { clientSecret, publishableKey: this.stripe.publishableKey ?? '' }
|
||||
}
|
||||
|
||||
// Finish a card-update: make the newly-attached PM the default and return the
|
||||
// resulting card so the UI can refresh without a second round-trip.
|
||||
async setDefaultPaymentMethod(
|
||||
slug: string,
|
||||
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||
paymentMethodId: string,
|
||||
auditActor?: AuditActor,
|
||||
): Promise<{ brand: string; last4: string; expMonth: number; expYear: number } | null> {
|
||||
const { tenant, sub } = await this.resolveTenantSub(slug, actor)
|
||||
const customerId = await this.ensureStripeCustomer(tenant, sub)
|
||||
await this.stripe.setDefaultCard(customerId, paymentMethodId, sub?.stripeSubscriptionId)
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'billing.payment_method_updated',
|
||||
resourceType: 'subscription',
|
||||
resourceId: tenant.slug,
|
||||
resourceName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
source: 'portal',
|
||||
},
|
||||
auditActor,
|
||||
)
|
||||
return this.stripe.getDefaultCard(customerId)
|
||||
}
|
||||
|
||||
// ── Stripe webhook ────────────────────────────────────────────────────────
|
||||
// Idempotent upsert by stripeInvoiceId / subscription id. Only meaningful
|
||||
// when Stripe is enabled; no-op acknowledgement otherwise.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'
|
||||
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 { BillingService } from './billing.service.js'
|
||||
|
||||
// Customer-admin billing reads, scoped to a single tenant the caller belongs
|
||||
// to. Lives in BillingModule (not TenantsModule) to avoid a module cycle:
|
||||
// TenantsModule → BillingModule → UsersModule → TenantsModule. Only
|
||||
// JwtAuthGuard here (any portal user); per-tenant membership is enforced in
|
||||
// BillingService.tenantInvoicesForActor.
|
||||
@Controller('tenants')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CustomerBillingController {
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly actor: ActorService,
|
||||
) {}
|
||||
|
||||
@Get(':slug/invoices')
|
||||
async invoices(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
return this.billing.tenantInvoicesForActor(slug, user)
|
||||
}
|
||||
|
||||
// The card currently on file (or null). Read-only.
|
||||
@Get(':slug/payment-method')
|
||||
async paymentMethod(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
return this.billing.getPaymentMethod(slug, user)
|
||||
}
|
||||
|
||||
// Start a card update — returns the SetupIntent client secret + publishable
|
||||
// key for the portal to confirm with Stripe Elements.
|
||||
@Post(':slug/payment-method/setup-intent')
|
||||
async setupIntent(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
return this.billing.createPaymentSetupIntent(slug, user)
|
||||
}
|
||||
|
||||
// Finish the update: set the confirmed payment method as the default.
|
||||
@Post(':slug/payment-method/default')
|
||||
async setDefault(
|
||||
@Param('slug') slug: string,
|
||||
@Body('paymentMethodId') paymentMethodId: string,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
if (!paymentMethodId) throw new BadRequestException('paymentMethodId is required')
|
||||
const user = await this.actor.resolve(jwt)
|
||||
return this.billing.setDefaultPaymentMethod(slug, user, paymentMethodId, {
|
||||
userId: String(user._id),
|
||||
email: user.email,
|
||||
ip: clientIp(req),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,15 @@ export class StripeClient {
|
||||
private readonly logger = new Logger(StripeClient.name)
|
||||
private readonly secretKey?: string
|
||||
private readonly webhookSecret?: string
|
||||
// Publishable key is safe to hand to the browser — it's how the portal mounts
|
||||
// Stripe Elements. Exposed via getter so the billing endpoints can return it.
|
||||
readonly publishableKey?: string
|
||||
private _stripe?: Stripe
|
||||
readonly enabled: boolean
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.secretKey = config.get<string>('STRIPE_SECRET_KEY')
|
||||
this.publishableKey = config.get<string>('STRIPE_PUBLISHABLE_KEY')
|
||||
this.webhookSecret = config.get<string>('STRIPE_WEBHOOK_SECRET')
|
||||
this.enabled = config.get<string>('BILLING_STRIPE_ENABLED') === 'true' && !!this.secretKey
|
||||
if (!this.enabled) {
|
||||
@@ -138,6 +142,59 @@ export class StripeClient {
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── Payment methods (customer-admin "card on file" flow) ──────────────────
|
||||
|
||||
// The customer's default card, if any. Prefers the invoice-settings default;
|
||||
// falls back to the first attached card. Returns null when none is on file.
|
||||
async getDefaultCard(customerId: string): Promise<{
|
||||
brand: string
|
||||
last4: string
|
||||
expMonth: number
|
||||
expYear: number
|
||||
} | null> {
|
||||
const customer = await this.stripe.customers.retrieve(customerId, {
|
||||
expand: ['invoice_settings.default_payment_method'],
|
||||
})
|
||||
if (customer.deleted) return null
|
||||
|
||||
let pm = customer.invoice_settings?.default_payment_method as Stripe.PaymentMethod | null
|
||||
if (!pm || typeof pm === 'string') {
|
||||
const list = await this.stripe.paymentMethods.list({ customer: customerId, type: 'card', limit: 1 })
|
||||
pm = list.data[0] ?? null
|
||||
}
|
||||
if (!pm?.card) return null
|
||||
return {
|
||||
brand: pm.card.brand,
|
||||
last4: pm.card.last4,
|
||||
expMonth: pm.card.exp_month,
|
||||
expYear: pm.card.exp_year,
|
||||
}
|
||||
}
|
||||
|
||||
// SetupIntent to collect a card off-session for future invoices. The portal
|
||||
// confirms it client-side with Stripe Elements using the returned secret.
|
||||
async createSetupIntent(customerId: string): Promise<string> {
|
||||
const si = await this.stripe.setupIntents.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ['card'],
|
||||
usage: 'off_session',
|
||||
})
|
||||
if (!si.client_secret) throw new Error('SetupIntent has no client_secret')
|
||||
return si.client_secret
|
||||
}
|
||||
|
||||
// After a SetupIntent succeeds, make the new card the customer's default for
|
||||
// invoices (and for the active subscription, if any). The PM is already
|
||||
// attached to the customer by the SetupIntent.
|
||||
async setDefaultCard(customerId: string, paymentMethodId: string, subscriptionId?: string): Promise<void> {
|
||||
await this.stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: paymentMethodId },
|
||||
})
|
||||
if (subscriptionId) {
|
||||
await this.stripe.subscriptions.update(subscriptionId, { default_payment_method: paymentMethodId })
|
||||
}
|
||||
}
|
||||
|
||||
constructWebhookEvent(rawBody: Buffer | string, signature: string): Stripe.Event {
|
||||
if (!this.webhookSecret) throw new Error('Stripe webhook secret not configured')
|
||||
return this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type TenantBrandingDocument = HydratedDocument<TenantBranding>
|
||||
|
||||
// A single email-template override. `key` identifies the template
|
||||
// ('invitation', 'reset', 'digest', 'trial'); subject/body are the customer's
|
||||
// edited copy. Absence of a row for a key means "use the platform default",
|
||||
// which lives in the portal (canonical defaults) — we only persist overrides.
|
||||
@Schema({ _id: false })
|
||||
export class EmailTemplateOverride {
|
||||
@Prop({ required: true, trim: true })
|
||||
key!: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
subject!: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
body!: string
|
||||
}
|
||||
const EmailTemplateOverrideSchema = SchemaFactory.createForClass(EmailTemplateOverride)
|
||||
|
||||
// Per-tenant whitelabel branding that doesn't fit on the Tenant doc itself.
|
||||
// Name + brandColor live on Tenant (source of truth); this holds the email
|
||||
// template overrides. Logos / custom-domain verification will join here once
|
||||
// their pipelines exist. One doc per tenant, keyed by tenantId.
|
||||
@Schema({ collection: 'tenant_branding', timestamps: true })
|
||||
export class TenantBranding {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, unique: true, index: true })
|
||||
tenantId!: Types.ObjectId
|
||||
|
||||
@Prop({ type: [EmailTemplateOverrideSchema], default: [] })
|
||||
emailTemplates!: EmailTemplateOverride[]
|
||||
}
|
||||
|
||||
export const TenantBrandingSchema = SchemaFactory.createForClass(TenantBranding)
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator'
|
||||
|
||||
// Customer-editable company/tax fields for invoicing. Deliberately narrow —
|
||||
// this is the only tenant mutation a customer admin can perform from the
|
||||
// portal, so it must not carry plan/status/partner fields. contactEmail is a
|
||||
// plain string (not @IsEmail) so it can be cleared to '' without a 400.
|
||||
export class UpdateBillingInfoDto {
|
||||
@IsOptional() @IsString() @MaxLength(160)
|
||||
companyName?: string
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(64)
|
||||
vatId?: string
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(80)
|
||||
country?: string
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(160)
|
||||
contactEmail?: string
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Type } from 'class-transformer'
|
||||
import {
|
||||
IsArray,
|
||||
IsHexColor,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator'
|
||||
|
||||
export class EmailTemplateDto {
|
||||
@IsString() @MaxLength(40)
|
||||
key!: string
|
||||
|
||||
@IsString() @MaxLength(200)
|
||||
subject!: string
|
||||
|
||||
@IsString() @MaxLength(20000)
|
||||
body!: string
|
||||
}
|
||||
|
||||
// Customer-editable whitelabel branding. Narrow on purpose: name + accent
|
||||
// color (written to the Tenant doc) and email-template overrides (written to
|
||||
// TenantBranding). No plan/status/partner — and no logo/domain yet (no
|
||||
// backend). brandColor accepts '#rrggbb'.
|
||||
export class UpdateTenantBrandingDto {
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(120)
|
||||
name?: string
|
||||
|
||||
@IsOptional() @IsHexColor()
|
||||
brandColor?: string
|
||||
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => EmailTemplateDto)
|
||||
emailTemplates?: EmailTemplateDto[]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { TenantBranding, type TenantBrandingDocument } from '../schemas/tenant-branding.schema.js'
|
||||
import { Tenant, type TenantDocument } from '../schemas/tenant.schema.js'
|
||||
import type { UpdateTenantBrandingDto } from './dto/update-tenant-branding.dto.js'
|
||||
|
||||
export interface TenantBrandingView {
|
||||
name: string
|
||||
brandColor?: string
|
||||
primaryDomain?: string
|
||||
emailTemplates: Array<{ key: string; subject: string; body: string }>
|
||||
}
|
||||
|
||||
// Customer whitelabel branding. Name + brandColor are read/written on the
|
||||
// Tenant doc (source of truth); email-template overrides live in the
|
||||
// TenantBranding doc. The portal page reads the combined view and saves the
|
||||
// whole thing back in one PUT (same full-replace shape as partner branding).
|
||||
@Injectable()
|
||||
export class TenantBrandingService {
|
||||
constructor(
|
||||
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||
@InjectModel(TenantBranding.name) private readonly brandingModel: Model<TenantBrandingDocument>,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async get(tenant: TenantDocument): Promise<TenantBrandingView> {
|
||||
const branding = await this.brandingModel.findOne({ tenantId: tenant._id }).exec()
|
||||
return {
|
||||
name: tenant.name,
|
||||
brandColor: tenant.brandColor,
|
||||
primaryDomain: tenant.domains?.[0],
|
||||
emailTemplates: (branding?.emailTemplates ?? []).map((t) => ({
|
||||
key: t.key,
|
||||
subject: t.subject,
|
||||
body: t.body,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async put(
|
||||
tenant: TenantDocument,
|
||||
dto: UpdateTenantBrandingDto,
|
||||
actor?: AuditActor,
|
||||
): Promise<TenantBrandingView> {
|
||||
// Name + accent live on the Tenant doc.
|
||||
const set: Record<string, unknown> = {}
|
||||
if (dto.name !== undefined) set.name = dto.name
|
||||
if (dto.brandColor !== undefined) set.brandColor = dto.brandColor
|
||||
if (Object.keys(set).length) {
|
||||
await this.tenantModel.updateOne({ _id: tenant._id }, { $set: set }).exec()
|
||||
}
|
||||
|
||||
// Email-template overrides are a full replace (the page edits the whole set).
|
||||
if (dto.emailTemplates !== undefined) {
|
||||
await this.brandingModel
|
||||
.findOneAndUpdate(
|
||||
{ tenantId: tenant._id },
|
||||
{ $set: { emailTemplates: dto.emailTemplates } },
|
||||
{ upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true },
|
||||
)
|
||||
.exec()
|
||||
}
|
||||
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'tenant.branding_updated',
|
||||
resourceType: 'tenant',
|
||||
resourceId: String(tenant._id),
|
||||
resourceName: dto.name ?? tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: {
|
||||
fields: Object.keys(set),
|
||||
templates: dto.emailTemplates?.length ?? 0,
|
||||
},
|
||||
},
|
||||
actor,
|
||||
)
|
||||
|
||||
const fresh = await this.tenantModel.findById(tenant._id).exec()
|
||||
return this.get(fresh ?? tenant)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
@@ -17,9 +19,12 @@ import { CurrentUser } from '../auth/current-user.decorator.js'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||
import { OperatorGuard } from '../auth/operator.guard.js'
|
||||
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||
import type { AuditActor } from '../audit/audit.service.js'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto.js'
|
||||
import { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
|
||||
import { UpdateTenantBrandingDto } from './dto/update-tenant-branding.dto.js'
|
||||
import { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
||||
import { TenantBrandingService } from './tenant-branding.service.js'
|
||||
import { TenantsService } from './tenants.service.js'
|
||||
|
||||
// Build the audit actor from a resolved User doc + the originating request.
|
||||
@@ -41,6 +46,8 @@ export class TenantsController {
|
||||
constructor(
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly actor: ActorService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly branding: TenantBrandingService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -79,6 +86,29 @@ export class TenantsController {
|
||||
return this.tenants.listUsersForTenant(slug)
|
||||
}
|
||||
|
||||
// Tenant-scoped audit slice for the customer-admin dashboard. Same membership
|
||||
// gate as GET :slug — any member of the tenant can read their own workspace's
|
||||
// activity. This is the portal-accessible counterpart to the operator-only
|
||||
// GET /audit (which requires dezky-operator audience). Filtered strictly by
|
||||
// tenantSlug so a caller only ever sees their own tenant's events.
|
||||
@Get(':slug/audit')
|
||||
async listAudit(
|
||||
@Param('slug') slug: string,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
const parsed = limit ? Number.parseInt(limit, 10) : undefined
|
||||
return this.audit.list({
|
||||
tenantSlug: slug,
|
||||
limit: Number.isFinite(parsed) ? parsed : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@Patch(':slug')
|
||||
async update(
|
||||
@Param('slug') slug: string,
|
||||
@@ -94,6 +124,51 @@ export class TenantsController {
|
||||
return this.tenants.update(slug, dto, auditActor(user, req))
|
||||
}
|
||||
|
||||
// Customer admins can fix their own company/tax details. Narrow on purpose —
|
||||
// only billingInfo, never plan/status/partner (unlike the broad PATCH above).
|
||||
// Membership-gated like the other tenant-scoped portal reads.
|
||||
@Patch(':slug/billing-info')
|
||||
async updateBillingInfo(
|
||||
@Param('slug') slug: string,
|
||||
@Body() dto: UpdateBillingInfoDto,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return this.tenants.updateBillingInfo(slug, dto, auditActor(user, req))
|
||||
}
|
||||
|
||||
// Whitelabel branding (name + accent + email-template overrides). Narrow,
|
||||
// membership-gated — never touches plan/status/partner.
|
||||
@Get(':slug/branding')
|
||||
async getBranding(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return this.branding.get(tenant)
|
||||
}
|
||||
|
||||
@Put(':slug/branding')
|
||||
async putBranding(
|
||||
@Param('slug') slug: string,
|
||||
@Body() dto: UpdateTenantBrandingDto,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return this.branding.put(tenant, dto, auditActor(user, req))
|
||||
}
|
||||
|
||||
@Delete(':slug')
|
||||
@HttpCode(204)
|
||||
async remove(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
|
||||
|
||||
@@ -5,9 +5,11 @@ import { AuthModule } from '../auth/auth.module.js'
|
||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||
import { PricesModule } from '../prices/prices.module.js'
|
||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||
import { TenantBranding, TenantBrandingSchema } from '../schemas/tenant-branding.schema.js'
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||
import { ProvisioningService } from './provisioning.service.js'
|
||||
import { TenantBrandingService } from './tenant-branding.service.js'
|
||||
import { TenantsController } from './tenants.controller.js'
|
||||
import { TenantsService } from './tenants.service.js'
|
||||
|
||||
@@ -20,6 +22,7 @@ import { TenantsService } from './tenants.service.js'
|
||||
// provisioned tenant gets its Subscription doc in the same call. Price
|
||||
// lookup goes through PricesService for the soft-active filter.
|
||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||
{ name: TenantBranding.name, schema: TenantBrandingSchema },
|
||||
]),
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
@@ -27,7 +30,7 @@ import { TenantsService } from './tenants.service.js'
|
||||
PricesModule,
|
||||
],
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService, ProvisioningService],
|
||||
providers: [TenantsService, ProvisioningService, TenantBrandingService],
|
||||
exports: [TenantsService],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||
import type { PartnerUpdateTenantDto } from '../me/dto/partner-update-tenant.dto.js'
|
||||
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
|
||||
import type { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
|
||||
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
||||
import { ProvisioningService } from './provisioning.service.js'
|
||||
|
||||
@@ -244,6 +245,39 @@ export class TenantsService {
|
||||
return tenant
|
||||
}
|
||||
|
||||
// Narrow update of just the company/tax fields, for the customer-admin billing
|
||||
// page. Uses dotted $set so only the provided keys change (and '' clears one)
|
||||
// without disturbing the rest of billingInfo.
|
||||
async updateBillingInfo(
|
||||
slug: string,
|
||||
dto: UpdateBillingInfoDto,
|
||||
actor?: AuditActor,
|
||||
): Promise<TenantDocument> {
|
||||
const set: Record<string, unknown> = {}
|
||||
if (dto.companyName !== undefined) set['billingInfo.companyName'] = dto.companyName
|
||||
if (dto.vatId !== undefined) set['billingInfo.vatId'] = dto.vatId
|
||||
if (dto.country !== undefined) set['billingInfo.country'] = dto.country
|
||||
if (dto.contactEmail !== undefined) set['billingInfo.contactEmail'] = dto.contactEmail
|
||||
|
||||
const tenant = await this.tenantModel
|
||||
.findOneAndUpdate({ slug }, { $set: set }, { new: true, runValidators: true })
|
||||
.exec()
|
||||
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
|
||||
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'tenant.billing_info_updated',
|
||||
resourceType: 'tenant',
|
||||
resourceId: String(tenant._id),
|
||||
resourceName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: { fields: Object.keys(set) },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
return tenant
|
||||
}
|
||||
|
||||
async softDelete(slug: string, actor?: AuditActor): Promise<void> {
|
||||
const result = await this.tenantModel
|
||||
.findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })
|
||||
|
||||
Reference in New Issue
Block a user