-
{{ user?.userInfo?.email || user?.userName }}
-
sign out
+
+
+
-
-
- Workspace · welcome
- Hi, {{ user?.userInfo?.name || user?.userName }}.
- Sovereign workspace platform · all your services in one place.
-
+
+
+
+
+
+
+
+
{{ t.name }}
+
{{ t.badge }}
+
+
+
-
-
+
+
+
+
+
+
+ View calendar
+
+
+
+
+
{{ m.time }}
+
+
in {{ m.in }}
+
Join
+
+
+
+
+
+
+
+
+
{{ f.name }}
+
{{ f.path }} · {{ f.updated }}
+
+
+
+
+
+
+
+
+
+
+
+
Needs your attention
+
Pending · {{ needsAttention.length }} items
+
+
+ See all
+
+
+
+
+
+
+
+
+
+
{{ t.title }}
+
{{ t.hint }}
+
+
+ {{ t.cta }}
+
+
+
+
+
+
+
+
+
+
+
// announcement
+
We're moving to single-sign-on next Monday. Set up your authenticator app this week.
+
posted by Anne · 2h ago
+
+
Set it up
+
+
+
+
+
System
+
All services operational
+
+
+
+
{{ s }}
+
+
+ operational
+
+
+
+
+
+
+
+
+
+
+
+ preview not available · open in Drev to view
+
+
+
+
+
+ Copy link
+
+
+
+ Manage access
+
+
+
+ Star
+
+
+
+
+ Close
+
+
+
+ Download
+
+
+
+ Open in Drev
+
+
+
+
+
+
+
+
+
+
+
Meeting {{ joining?.title }}
+
With {{ joining?.with }}
+
Starts {{ joining?.time }} · in {{ joining?.in }}
+
Room meet.dezky.com/{{ joining?.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') }}
+
+
+
+
+
+
+
Microphone
+
{{ joinMic ? 'unmuted' : 'muted' }}
+
+
+
+
+
+
Camera
+
{{ joinCam ? 'on' : 'off' }}
+
+
+
+
+
+ Cancel
+
+
+ Join now
+
+
+
diff --git a/apps/portal/pages/partner/audit.vue b/apps/portal/pages/partner/audit.vue
new file mode 100644
index 0000000..2187a18
--- /dev/null
+++ b/apps/portal/pages/partner/audit.vue
@@ -0,0 +1,407 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Actor
+
+ Anyone
+ {{ a }}
+
+
+
+
+ Customer
+
+ All customers
+ {{ name }}
+
+
+
+
+ Action
+
+ All actions
+ {{ a }}
+
+
+
+
+ Last
+
+ 24 hours
+ 7 days
+ 30 days
+
+
+
+
+
+
+ Export CSV
+
+
+
+
+
+
+
+ Time
+ Actor
+ Customer
+ Action
+ Target
+
+
+
+
+
+ {{ r.when }}
+
+
+
+
+
{{ r.actor }}
+
partner
+
+
+
+
+ —
+
+
+ {{ r.action }}
+ {{ r.target }}
+
+ {{ r.tone === 'bad' ? 'fail' : r.tone }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ detail.when }}
+ {{ detail.tone === 'bad' ? 'fail' : detail.tone }}
+
+
+
+
Actor
+
+
+
+
{{ detail.actor }}
+
partner staff
+
+
+
+
+
+
Target
+
+
+
+
{{ detail.customer }}
+
+
{{ detail.target }}
+
+
+
+
+
Event ID
+
{{ detail.id }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/partner/billing.vue b/apps/portal/pages/partner/billing.vue
new file mode 100644
index 0000000..73537db
--- /dev/null
+++ b/apps/portal/pages/partner/billing.vue
@@ -0,0 +1,302 @@
+
+
+
+
+
+
+
+
+ Export
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Per customer · this month
+
Revenue breakdown
+
+
+
+
+
+ Customer
+ Plan
+ Seats
+ MRR
+ Partner cut ({{ partner.marginPct }}%)
+ Status
+
+
+
+
+
+
+
+
+
{{ c.name }}
+
{{ c.domain }}
+
+
+
+ {{ c.planLabel }}
+ {{ c.seats.used }}
+ {{ c.mrrDkk.toLocaleString('da-DK') }} DKK
+ {{ Math.round(c.mrrDkk * partner.marginPct / 100).toLocaleString('da-DK') }} DKK
+
+ {{ statusBadge(c.status).label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Invoice
+ Customer
+ Date
+ Amount
+ Status
+
+
+
+
+
+ {{ inv.number }}
+ {{ inv.customer }}
+ {{ inv.date }}
+ {{ inv.amount.toLocaleString('da-DK') }} DKK
+
+ {{ inv.status.replace('_', '-') }}
+
+
+
+
+ PDF
+
+
+
+
+
+
+
+
+
+
+
+
+ Margin
+ Your reseller margin
+ Per your agreement with Dezky · 20% gross on all customer revenue.
+
+
Starter plan 20% · 9,80 DKK per seat / mo
+
Business plan 20% · 25,80 DKK per seat / mo
+
Enterprise plan 15% · negotiated per customer
+
Add-ons Pass-through · 0%
+
Volume rebate +2% over 200 active seats · qualifies
+
+
+
+
+ Revenue · 12 months
+ Trailing twelve
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Period
+ Amount
+ Paid on
+ Reference
+ Status
+
+
+
+
+ {{ p.period }}
+ {{ p.amt }} DKK
+ {{ p.paid }}
+ {{ p.ref }}
+
+ {{ p.status }}
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/partner/branding.vue b/apps/portal/pages/partner/branding.vue
new file mode 100644
index 0000000..f6b0984
--- /dev/null
+++ b/apps/portal/pages/partner/branding.vue
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
+
+
+
+
Your brand
+
NordicMSP identity
+
Shown in the partner console and on emails sent by your team.
+
+
Edit
+
+
+
+
Display name NordicMSP
+
Logo nordic-logo.svg · 4:1 horizontal
+
Mark nordic-mark.svg · 1:1
+
+
+
+
Support email support@nordicmsp.dk
+
Support phone +45 70 70 12 34
+
Website nordicmsp.dk
+
Reply-to no-reply@nordicmsp.dk
+
+
+
+
+
+
+
+
+
Customer defaults
+
What gets pushed to new customers
+
Applied at provisioning. Customers can override per their tier entitlements.
+
+
+
+
+
+
+
+ Templates
+ Email templates · NordicMSP defaults
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/partner/customers.vue b/apps/portal/pages/partner/customers.vue
new file mode 100644
index 0000000..3c68554
--- /dev/null
+++ b/apps/portal/pages/partner/customers.vue
@@ -0,0 +1,530 @@
+
+
+
+
+
+
+
+
+ Export CSV
+
+
+
+ New customer
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Status
+
+ {{ o.label }}
+
+
+
+
+ Plan
+
+ {{ o.label }}
+
+
+
+
+
+
{{ filtered.length }} of {{ customers.length }}
+
+
+ {{ v }}
+
+
+
+
+
+
+
+
+
+ Customer
+ Plan
+ Seats
+ MRR
+ Status
+ Customer since
+
+
+
+
+
+
+
+
+
+
{{ c.name }}
+
{{ c.domain }}
+
+
+
+
+ {{ c.planLabel }}
+
+
+ {{ c.seats.used }}/{{ c.seats.total }}
+
+
+ {{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}
+
+
+ {{ statusBadge(c.status).label }}
+
+ {{ c.since }}
+
+
+ Manage
+
+
+
+
+
+ No customers match these filters.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ c.name }}
+
{{ c.domain }}
+
+
{{ statusBadge(c.status).label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/partner/index.vue b/apps/portal/pages/partner/index.vue
new file mode 100644
index 0000000..fb51ae1
--- /dev/null
+++ b/apps/portal/pages/partner/index.vue
@@ -0,0 +1,573 @@
+
+
+
+
+
+
+
+
+ Export report
+
+
+
+ New customer
+
+
+
+
+
+
+
+
+
+
Current MRR
+
+
+ 0 DKK / mo
+
+
+
+ {{ t.majorAmount.toLocaleString('da-DK') }}
+ {{ t.currency }} / mo
+
+
+
+ custom-priced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Health
+
Customer status
+
+
{{ healthyCount }} healthy of {{ totalCustomers }}
+
+
+ // no customers yet — provision your first from the New customer button
+
+
+
+
+ {{ t.name }}
+
+
+ {{ t.planLabel }} · {{ t.usedSeats }}/{{ t.totalSeats }}
+ {{ t.slug }}
+
+
+
+
+
+
+
+
Attention
+
What needs your attention
+
+
+
+
+
+
+
+
+
+
+
Activity
+
Recent across portfolio
+
+
+ View all
+
+
+
+
+ // no recent events yet
+
+
+
+
{{ a.when }}
+
+ {{ a.cust }}
+
+
+ {{ a.who }} {{ a.action }}
+
+
+ {{ a.tone }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/partner/reports.vue b/apps/portal/pages/partner/reports.vue
new file mode 100644
index 0000000..67cf7ba
--- /dev/null
+++ b/apps/portal/pages/partner/reports.vue
@@ -0,0 +1,764 @@
+
+
+
+
+
+
+
+
+ Export PDF
+
+
+
+ New report
+
+
+
+
+
+
+
+ Period
+
+ {{ o.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Per customer
+
Health scores
+
+
+
+
+
+ Customer
+ Plan
+ Health
+ Seat usage
+ MRR
+ Trend · 90d
+
+
+
+
+
+
+
+
+
+
{{ c.name }}
+
{{ c.domain }}
+
+
+
+
+ {{ c.planLabel }}
+
+
+
+
+ {{ c.seats.used }}/{{ c.seats.total }} · {{ Math.round(c.seats.used/c.seats.total*100) }}%
+ {{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' DKK' : '—' }}
+
+
+
+
+ Escalate
+ Check in
+ —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
MRR · last 90 days
+
Trend
+
+
+
+
+
+
+
+ By plan
+ Revenue mix
+
+
+
+ {{ p.n }}
+ {{ p.v.toLocaleString('da-DK') }} DKK · {{ p.p }}%
+
+
+
+
+
+
+ Top customers
+ By MRR
+
+
+
+
{{ c.name }}
+
{{ c.mrrDkk.toLocaleString('da-DK') }} DKK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Cohort retention
+
Customers by signup month
+
+
+
+
+
+
+ Cohort
+ Size
+ {{ h }}
+
+
+
+
+ {{ c[0] }}
+ {{ c[1] }}
+
+ —
+ {{ v }}%
+
+
+
+
+
+
+
+
+ Why customers leave
+ Top exit reasons (last 12 months)
+
+ No churn yet. When customers do leave, exit reasons will surface here automatically (from cancel/pause flow inputs).
+
+
+
+
+
+
+
+
+ Build a report once, schedule or run on demand. We'll email a PDF to the recipients you specify.
+
+
+
+ New custom report
+
+
+
+
+
+
+
+ Report
+ Schedule
+ Owner
+ Last run
+
+
+
+
+
+ {{ r.name }}
+ {{ r.schedule }}
+
+
+
+ {{ r.last }}
+
+
+
+
+
+
+ {{ running === r.id ? 'Running…' : 'Run' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The report configuration and its schedule will be deleted. Past PDFs already delivered to recipients are unaffected.
+
+
+
+
+
Report {{ confirmDeleteReport.name }}
+
Schedule {{ confirmDeleteReport.schedule }}
+
Recipients {{ confirmDeleteReport.recipients }} addresses
+
Last run {{ confirmDeleteReport.last }}
+
+
+
+
+ Cancel
+
+
+ Delete report
+
+
+
+
+
toast.ok(t.mode === 'escalate' ? 'Escalation created' : 'Check-in scheduled', t.customer.name)"
+ />
+ toast.ok('Report created', n)"
+ />
+
+
+
+ Select which tabs to include in the PDF, the period, cover style, and how you want it delivered.
+
+ // pdf preview · 3 sections · estimated 11 pages · NordicMSP cover + footer
+
+
+ Cancel
+
+
+ Download PDF
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/partner/settings.vue b/apps/portal/pages/partner/settings.vue
new file mode 100644
index 0000000..a98be7f
--- /dev/null
+++ b/apps/portal/pages/partner/settings.vue
@@ -0,0 +1,326 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Reseller agreement
+
Active · v2025.11
+
Effective since 14 Jan 2024 · auto-renews 14 Jan 2027
+
+
+
+
+ Download PDF
+
+ View history
+
+
+
+
+
Tier Tier 2 · Established
+
Default margin 20% on Starter & Business
+
Enterprise margin Negotiated · 15% baseline
+
Volume rebate +2% over 200 active seats · qualifies
+
Payout cadence Monthly · 3rd business day
+
Min commitment 5 active customers
+
+
+
Effective 14 Jan 2024
+
Term 36 months · auto-renew
+
Notice period 90 days written
+
Liability cap 12 months of fees
+
Governing law Denmark · Copenhagen
+
Signed by Anne Baslund · NordicMSP
+
+
+
+
+
+ Documents
+ Related files
+
+
+
+ {{ d.n }}
+ {{ d.size }} · {{ d.date }}
+
+
+
+
+
+
+
+
+
+
+
+
Business
+
NordicMSP company info
+
+
Edit
+
+
+
+
+
+
+
+
+ Identification
+ Tax & invoicing
+
+
+
Country Denmark
+
CVR 41 88 22 04
+
VAT number DK 41 88 22 04
+
VAT rate 25% · standard DK
+
+
+
Currency DKK · EUR available
+
Invoicing OIOUBL · NemHandel
+
EAN/GLN 5790000123456
+
Tax exempt No
+
+
+
+
+
+
+
+
Payout method
+
Where Dezky pays your margin
+
+
Change
+
+
+
+
+
+
+
+
+
+
+
Partner-level events
+
Where to send each event
+
+
+
+
+ {{ row.event }}
+ {{ row.when }}
+ {{ row.channels }}
+ Edit
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/partner/team.vue b/apps/portal/pages/partner/team.vue
new file mode 100644
index 0000000..cc6c520
--- /dev/null
+++ b/apps/portal/pages/partner/team.vue
@@ -0,0 +1,286 @@
+
+
+
+
+
+
+
+
+ Invite teammate
+
+
+
+
+
+
+
+
+
+ Name
+ Role
+ Customer access
+ MFA
+ Last seen
+
+
+
+
+
+
+
+
+
+
{{ m.name }}
+
{{ m.email }}
+
+
+
+
+ {{ m.role }}
+
+ {{ accessLabel(m) }}
+ enabled
+ {{ m.lastSeen }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/profile.vue b/apps/portal/pages/profile.vue
new file mode 100644
index 0000000..5b48b48
--- /dev/null
+++ b/apps/portal/pages/profile.vue
@@ -0,0 +1,904 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ B
+ I
+ U
+ ⇿
+ link
+ image
+
+
+
Anne Baslund
+
Founder · CEO at baslund
+
+45 21 47 88 02 · anne@dezky.com
+
+
+
+ // available variables
+ {{ AVAILABLE_VARS_TEXT }}
+
+
+
+
+
+
+
+
+
+
+ // no overrides · all accounts use the default signature
+
+
+
+
+
+
+
+
+
Channels · per event
+
Notification preferences
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drop a photo here, or click to browse
+
jpg · png · webp · up to 5 MB · best at 512×512
+
+
+
+
+
+
Crop preview
+
+
+ Horizontal position
+
+
+
+
+
Final result
+
A
+
large · 88×88
+
A
+
chat · 32×32
+
+
+ Replace photo
+
+
+
// where it appears
+
Profile pages, Chat avatars, mail headers, meeting tiles, and the partner console. Co-workers in your workspace can see it.
+
+
+
+
+
+ Remove
+
+
+ Cancel
+
+
+ {{ photo.uploaded ? 'Use this photo' : 'Select a photo' }}
+
+
+
+
+
+
+
+
+
+ Push and email notifications are silenced during quiet hours. In-app indicators still update so nothing is missed — just no pings.
+
+
+
+
+
+
+
+
+
+
Pause on public holidays
+
silence all day on Danish public holidays
+
+
+
+
+
+
Always allow your manager
+
Mikkel Nørgaard can still reach you during quiet hours
+
+
+
+
+
+
Override for P1 incidents
+
always notify for on-call pages and security alerts
+
+
+
+
+
+
shown on your profile card so teammates know when not to expect you
+
+
+ Cancel
+
+
+ Save quiet hours
+
+
+
+
+
+
+
+
+
+
+
+
+
Applies to
+
+
+ {{ o.l }}
+ {{ o.d }}
+
+
+
+
+
+
+ *@
+
+
+
+
+
+
+
+
+
+
+
+
+ plain text · supports the same {{ MERGE_TAG_LITERAL }} variables as your default signature
+
+
+
+ Cancel
+
+
+ Save override
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/security.vue b/apps/portal/pages/security.vue
new file mode 100644
index 0000000..e118cb5
--- /dev/null
+++ b/apps/portal/pages/security.vue
@@ -0,0 +1,947 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Password updated · confirmation email sent
+
+
+
Cancel
+
+
+ Update password
+
+
+
+
+
+
+
+
+
+
+
+
+
Verify new address
+
+
+
+ We'll never use this address for marketing or workspace announcements — only account recovery and security alerts. Verified 14 Jan 2026 · last reachability check 2 days ago .
+
+
+
+
+
+
+
+
+
+
+
Two-factor is on
+
You have {{ mfaMethods.length }} methods set up · workspace requires MFA for admins.
+
+
+
+
+
+
+ Active methods
+ {{ mfaMethods.length }}
+
+
+
+
+
+
+
+
+ {{ m.label }}
+ primary
+ {{ m.kind === 'webauthn' ? 'hardware key' : 'authenticator app' }}
+
+
added {{ m.enrolledOn }} · last used {{ m.lastUsed }}
+
+
+
+
+
+
+
+
+
+
+
+ Add security key (WebAuthn)
+
+
+
+ Add authenticator app (TOTP)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keep these somewhere safe — printed, password manager, or offline note. Each code works once. If you suspect they've been seen by someone else, regenerate immediately.
+
+
Regenerate
+
+
+
+
+
+
+
+
+ Every sign-in attempt on your account, successful or not. If you see something you don't recognize, change your password and revoke unknown sessions.
+
+
+
+ Export
+
+
+
+
+
+
+
+ Time
+ IP
+ Location
+ Client
+ Method
+ Result
+
+
+
+
+ {{ r.when }}
+ {{ r.ip }}
+ {{ r.location }}
+ {{ r.ua }}
+ {{ r.method }}
+
+
+ {{ r.result === 'ok' ? 'success' : 'failed' }}
+
+
+
+
+
+
+
+
+
+
+
3 failed attempts from 203.0.113.4 yesterday at 18:02
+
Unknown location. Your password is still safe, but consider rotating it.
+
+
Change password
+
+
+
+
+
+
+
+ We sent a 6-digit code to {{ backupEmail }} . Paste it below to confirm you control the inbox.
+
+
+
+
+
+ didn't get it? resend · expires in 10 min
+
+
+ Cancel
+
+
+ Verify
+
+
+
+
+
+
+
+
+
+ helps you tell methods apart when signing in or removing one
+
+ Cancel
+
+
+ Save name
+
+
+
+
+
+
+
+
+
+
You'll be required to re-enter your password to confirm. We'll send a confirmation email — if you didn't do this, contact your admin immediately.
+
+
+
+
Method {{ removeMfa?.label }}
+
Kind {{ removeMfa?.kind === 'webauthn' ? 'hardware security key' : 'authenticator app (TOTP)' }}
+
Added {{ removeMfa?.enrolledOn }}
+
Last used {{ removeMfa?.lastUsed }}
+
+
+
at least one MFA method must remain — workspace requires MFA for admins
+
+
+ Cancel
+
+
+ Remove method
+
+
+
+
+
+
+
+
+ Security keys are the strongest form of MFA. Use a hardware token (YubiKey) or your laptop's built-in Touch ID / Windows Hello.
+ You'll scan a QR code with an authenticator app like 1Password, Bitwarden, Authy, or Google Authenticator.
+
+
+
+
+
+
+
+
Waiting for your key…
+
// touch your YubiKey or use Touch ID
+
+
+
+ Scan this code with your authenticator app. Or copy the setup key and add it manually.
+
+
+
+
+
+
+
+
+
+
+
{{ mfaSetup === 'webauthn' ? 'Security key added' : 'App registered' }}
+ verified · ready to use on next sign-in
+
+
+
+ // next time you sign in
+ We'll ask you to use this method as your second factor. Make sure you have recovery codes saved somewhere safe in case you lose access to your authenticator.
+
+
+
+ Cancel
+
+ Back
+ Continue
+
+
+ Done · use this method
+
+
+
+
+
+
+
+ Save these somewhere offline. Each code works once . We'll show you any codes you haven't used yet.
+
+
+
+ {{ i + 1 < 10 ? '0' + (i + 1) : i + 1 }}
+ {{ c }}
+ used
+
+
+
+ Close
+
+
+
+ {{ copyFlash ? 'Copied · paste somewhere safe' : 'Copy all' }}
+
+
+
+ {{ downloadFlash ? 'Downloaded' : 'Download .txt' }}
+
+
+
+
+
+
+
+
+
+
Your 10 existing codes ({{ recoveryCodes.length - usedCount }} still unused) will be invalidated immediately. Anyone holding a printed copy will no longer be able to use them.
+
+
+
// when to do this
+
You're seeing this option to regenerate because either you used a code recently, or you suspect someone else has seen your saved codes. If neither, you can keep the existing ones.
+
+
+
+ I understand the old codes will stop working
+
+
+
+ Cancel
+
+
+ Generate 10 new codes
+
+
+
+
+
+
+
+
+
Period
+
+ {{ p.l }}
+
+
+
+
+
+ CSV
+ JSON
+
+
+
+
+
+ Include failed attempts
+
+
+
+
Delivery
+
+
+
+
+
Download now
+
browser download
+
+
+
+
+
+
Email to me
+
sent to anne@dezky.com
+
+
+
+
+
+
+
// estimated
+
~{{ exportRowEst }} rows · {{ exportIncludeFailed ? 'includes failed attempts' : 'successes only' }} · {{ exportFormat.toUpperCase() }}
+
+
+
+ Cancel
+
+
+ {{ exportDelivery === 'email' ? 'Email export' : 'Download' }}
+
+
+
+
+
+
+
diff --git a/apps/portal/pages/signed-out.vue b/apps/portal/pages/signed-out.vue
new file mode 100644
index 0000000..3ca0144
--- /dev/null
+++ b/apps/portal/pages/signed-out.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+ Sign in again
+
+
+ Closing the tab? Your data stays put on
+ app.dezky.local .
+
+
+
+
+
diff --git a/apps/portal/server/api/auth/sign-out.get.ts b/apps/portal/server/api/auth/sign-out.get.ts
new file mode 100644
index 0000000..0f57324
--- /dev/null
+++ b/apps/portal/server/api/auth/sign-out.get.ts
@@ -0,0 +1,42 @@
+// Sign-out. Ends both the portal session and the Authentik IdP session so
+// "Sign in again" always requires fresh credentials — even for the next
+// person at the same browser.
+//
+// Flow:
+// 1. Read the id_token off the local session (needed as id_token_hint).
+// 2. Clear the local nuxt-oidc-auth session (cookie + persistent store).
+// 3. 302 the BROWSER to Authentik's end-session endpoint with
+// post_logout_redirect_uri=/signed-out.
+//
+// Why the browser redirect (not server-to-server):
+// Authentik's session cookie lives on auth.dezky.local. Only a response
+// FROM that host can set/clear it — a server-side fetch invalidates the
+// session record but leaves the browser cookie intact, so the next OIDC
+// authorize succeeds silently. A 302 through Authentik fixes that: the
+// browser visits auth.dezky.local, receives Set-Cookie clearing the
+// session, then bounces back to /signed-out (the URL is in the provider's
+// redirect_uris list — see infrastructure setup).
+//
+// The brief URL-bar flash through auth.dezky.local is acceptable: no
+// Authentik UI renders (it's a redirect chain), and the Traefik middleware
+// on the authentik service would catch any stray landing on the dashboard
+// anyway.
+
+import { getUserSession, clearUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+const END_SESSION = 'https://auth.dezky.local/application/o/dezky-portal/end-session/'
+const POST_LOGOUT_REDIRECT = 'https://app.dezky.local/signed-out'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => ({} as any))
+ const idToken: string | undefined = (session as any).idToken
+
+ await clearUserSession(event).catch(() => {})
+
+ const params = new URLSearchParams({
+ post_logout_redirect_uri: POST_LOGOUT_REDIRECT,
+ ...(idToken && { id_token_hint: idToken }),
+ })
+
+ return sendRedirect(event, `${END_SESSION}?${params.toString()}`, 302)
+})
diff --git a/apps/portal/server/api/partner/activity.get.ts b/apps/portal/server/api/partner/activity.get.ts
new file mode 100644
index 0000000..fbecd90
--- /dev/null
+++ b/apps/portal/server/api/partner/activity.get.ts
@@ -0,0 +1,28 @@
+// Partner-scoped audit feed for the dashboard's Activity card.
+// Forwards to platform-api /me/partner/activity which OR's
+// partnerSlug + the partner's tenant slugs.
+
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ const q = getQuery(event)
+ const params = new URLSearchParams()
+ if (q.limit) params.set('limit', String(q.limit))
+ if (q.before) params.set('before', String(q.before))
+ const qs = params.toString()
+ try {
+ return await $fetch(`${base}/me/partner/activity${qs ? '?' + qs : ''}`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/apps/portal/server/api/partner/mrr.get.ts b/apps/portal/server/api/partner/mrr.get.ts
new file mode 100644
index 0000000..8d26006
--- /dev/null
+++ b/apps/portal/server/api/partner/mrr.get.ts
@@ -0,0 +1,23 @@
+// Monthly Recurring Revenue for the signed-in partner. Forwards to
+// platform-api /me/partner/mrr which sums active subscriptions
+// across the partner's tenants and normalizes to monthly DKK.
+
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ try {
+ return await $fetch(`${base}/me/partner/mrr`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/apps/portal/server/api/partner/tenants.get.ts b/apps/portal/server/api/partner/tenants.get.ts
new file mode 100644
index 0000000..fb180a5
--- /dev/null
+++ b/apps/portal/server/api/partner/tenants.get.ts
@@ -0,0 +1,23 @@
+// Partner customer-list for the customer portal's /partner/customers page.
+// Forwards the user's access token to platform-api /me/partner/tenants.
+// Returns 403 if the caller isn't partner staff.
+
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ try {
+ return await $fetch(`${base}/me/partner/tenants`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/apps/portal/server/api/partner/tenants.post.ts b/apps/portal/server/api/partner/tenants.post.ts
new file mode 100644
index 0000000..f224502
--- /dev/null
+++ b/apps/portal/server/api/partner/tenants.post.ts
@@ -0,0 +1,27 @@
+// Partner-staff self-serve customer create. Forwards the user's access
+// token to platform-api POST /me/partner/tenants, which scopes the
+// new tenant to the caller's partner regardless of what the body says.
+// Returns the created tenant doc.
+
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const body = await readBody(event)
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ try {
+ return await $fetch(`${base}/me/partner/tenants`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${accessToken}` },
+ body,
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/apps/portal/server/api/partner/users.get.ts b/apps/portal/server/api/partner/users.get.ts
new file mode 100644
index 0000000..05db8f8
--- /dev/null
+++ b/apps/portal/server/api/partner/users.get.ts
@@ -0,0 +1,25 @@
+// Partner team listing for the customer portal's /partner/team page.
+// Forwards the user's access token to platform-api /me/partner/users —
+// that endpoint resolves the partner from the caller's User.partnerId, so
+// no partner slug needs to leak into the URL. Returns 403 if the caller
+// isn't partner staff.
+
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ try {
+ return await $fetch(`${base}/me/partner/users`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/apps/portal/server/api/prices.get.ts b/apps/portal/server/api/prices.get.ts
new file mode 100644
index 0000000..029f801
--- /dev/null
+++ b/apps/portal/server/api/prices.get.ts
@@ -0,0 +1,24 @@
+// Read-only price catalog for the portal. Partner-staff need to see active
+// prices to render the customer-create wizard's Plan step. The platform-api
+// /prices GET is open to any authenticated JWT (operator-only mutations are
+// guarded separately), so a portal-aud token works fine here.
+
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ try {
+ return await $fetch(`${base}/prices`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/docs/NEXT-STEPS.md b/docs/NEXT-STEPS.md
index 4c4c176..80a1f6c 100644
--- a/docs/NEXT-STEPS.md
+++ b/docs/NEXT-STEPS.md
@@ -237,6 +237,38 @@ These were excluded from MVP for simplicity. When ready:
- [ ] Configure OIDC integration with Authentik
- [ ] Add to portal launcher
+## Billing & subscriptions — partial implementation
+
+**Implemented (Tier 1)** as of 2026-05-26:
+
+- Price catalog (`prices` collection): operator-edited plan/cycle/currency/per-seat
+ matrix. UI at `operator.dezky.local/pricing`.
+- Subscription auto-created on tenant provision with the resolved `priceId` +
+ `cycle` + `seats` snapshot.
+- Partner MRR aggregation: `GET /users/me/partner/mrr` sums active subscriptions,
+ normalized to monthly DKK. Surfaced on `app.dezky.local/partner` dashboard.
+
+**NOT implemented — deferred to a future billing pipeline:**
+
+- **Stripe integration** (Tier 2): no Stripe Customer/Subscription objects are
+ created when a tenant is provisioned. No checkout flow. No payment method
+ capture. The `Subscription.stripe*` placeholder fields stay empty.
+- **Invoices** (Tier 2): no invoice generation, no PDF rendering, no
+ past-due/dunning logic. The customer portal's `/admin/billing` page is
+ still fixture-driven.
+- **Partner payouts** (Tier 3): MRR display uses `Partner.marginPct` for
+ display purposes only — no actual payout calculation, no payout schedule,
+ no bank account capture, no Stripe Connect.
+- **Multi-currency** (Tier 3): catalog only supports DKK. EUR/USD entries
+ would need currency-conversion at MRR aggregation time.
+- **Plan-change prorating** (Tier 3): changing a tenant's plan mid-cycle
+ doesn't prorate or generate adjustment invoices.
+- **Tax handling** (Tier 3): no VAT calculation, no Stripe Tax wiring, no
+ reverse-charge handling for cross-border B2B.
+
+When ready for paying customers, the next investment is Tier 2 (Stripe +
+invoices). Tier 3 work should wait until there's enough volume to justify it.
+
## Decisions still open
These need to be made before public launch:
diff --git a/infrastructure/docker-compose/configs/authentik/rebrand-web.sh b/infrastructure/docker-compose/configs/authentik/rebrand-web.sh
new file mode 100755
index 0000000..39e839b
--- /dev/null
+++ b/infrastructure/docker-compose/configs/authentik/rebrand-web.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+# Patch Authentik's web bundle on container start to swap "authentik" branding
+# for "Dezky" in places that the Brand custom_css can't reach.
+#
+# Why this is a startup script rather than baked-in volume mounts:
+# - Web bundle filenames are version-stamped (FlowInterface-2025.10.4.js),
+# so pinned mounts break the first time you upgrade Authentik.
+# - sed-on-start works against whatever bundle the new image ships, as
+# long as the source strings haven't changed.
+#
+# Runs as root (compose sets user: "0") so it can write to /web/dist, then
+# drops privileges back to the image's default authentik user (uid 1000) via
+# setpriv before exec'ing dumb-init. The Authentik server itself never runs
+# with elevated privileges.
+#
+# If a future Authentik release renames or rewords "Powered by authentik",
+# this script will silently no-op and the original branding returns. Re-check
+# after each upgrade.
+
+set -eu
+
+# All bundles that contain the source string. Glob is intentional — locale
+# chunks are content-hashed and change on every Authentik release.
+PATCH_DIRS="/web/dist/flow /web/dist/chunks /web/dist/src/locales/chunks"
+
+for DIR in $PATCH_DIRS; do
+ [ -d "$DIR" ] || continue
+ for F in "$DIR"/*.js; do
+ [ -f "$F" ] || continue
+ sed -i 's/Powered by authentik/Powered by Dezky/g' "$F"
+ done
+done
+
+# Drop back to the image's default user (authentik = uid 1000) before
+# exec'ing Authentik's normal entrypoint chain.
+exec setpriv --reuid=1000 --regid=1000 --init-groups dumb-init -- ak "$@"
diff --git a/infrastructure/docker-compose/docker-compose.yml b/infrastructure/docker-compose/docker-compose.yml
index cefbeac..c47e264 100644
--- a/infrastructure/docker-compose/docker-compose.yml
+++ b/infrastructure/docker-compose/docker-compose.yml
@@ -183,7 +183,16 @@ services:
image: ghcr.io/goauthentik/server:2025.10
container_name: dezky-authentik
restart: unless-stopped
- command: server
+ # Override the image's entrypoint to run rebrand-web.sh first, then
+ # chain to dumb-init -- ak (the image's normal entrypoint). The script
+ # patches "Powered by authentik" → "Powered by Dezky" in the served
+ # web bundles on every container start, since those filenames are
+ # version-stamped and can't be safely bind-mounted across upgrades.
+ # Runs as root (user "0") so the sed can write to /web/dist; the script
+ # drops privileges back to uid 1000 before exec'ing ak.
+ user: "0"
+ entrypoint: ["/lifecycle/rebrand-web.sh"]
+ command: ["server"]
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PASSWORD: ${REDIS_PASSWORD}
@@ -202,6 +211,7 @@ services:
- authentik_certs:/certs
- authentik_templates:/templates
- ./configs/authentik/blueprints:/blueprints/custom:ro
+ - ./configs/authentik/rebrand-web.sh:/lifecycle/rebrand-web.sh:ro
networks: [dezky]
depends_on:
postgres:
@@ -213,6 +223,17 @@ services:
- traefik.http.routers.authentik.rule=Host(`auth.dezky.local`)
- traefik.http.routers.authentik.tls=true
- traefik.http.services.authentik.loadbalancer.server.port=9000
+ # Hide Authentik's end-user dashboard. Anything hitting
+ # auth.dezky.local/ (root) or /if/user/* gets bounced to
+ # app.dezky.local. Login flow paths (/flows/, /application/o/,
+ # /api/, /static/, /if/flow/) and the admin UI (/if/admin/) keep
+ # working. After OIDC sign-in the relying party callback fires
+ # first, so users never land on /if/user/ during a normal flow —
+ # this only matters for users who type auth.dezky.local directly.
+ - "traefik.http.middlewares.authentik-hide-dashboard.redirectregex.regex=^https://auth\\.dezky\\.local(/|/if/user(/.*)?)$"
+ - "traefik.http.middlewares.authentik-hide-dashboard.redirectregex.replacement=https://app.dezky.local/"
+ - "traefik.http.middlewares.authentik-hide-dashboard.redirectregex.permanent=false"
+ - traefik.http.routers.authentik.middlewares=authentik-hide-dashboard
authentik-worker:
image: ghcr.io/goauthentik/server:2025.10
@@ -440,6 +461,10 @@ services:
NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
volumes:
- ../../apps/portal:/app
+ # Read-only mount of the monorepo's shared packages so Nuxt's
+ # components.dirs entry (/shared-packages/ui/components) resolves
+ # inside this container. Same mount is added to the operator service.
+ - ../../packages:/shared-packages:ro
- portal_node_modules:/app/node_modules
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
networks: [dezky]
@@ -484,6 +509,8 @@ services:
NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
volumes:
- ../../apps/operator:/app
+ # Shared packages — see portal service for explanation.
+ - ../../packages:/shared-packages:ro
- operator_node_modules:/app/node_modules
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
networks: [dezky]
diff --git a/packages/ui/components/CountrySelect.vue b/packages/ui/components/CountrySelect.vue
new file mode 100644
index 0000000..d154f23
--- /dev/null
+++ b/packages/ui/components/CountrySelect.vue
@@ -0,0 +1,340 @@
+
+
+
+
+
+
+
+
+ {{ c.name }}
+ {{ c.code }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 0000000..61ab4eb
--- /dev/null
+++ b/packages/ui/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "@dezky/ui",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Shared UI components used by apps/portal and apps/operator. Auto-imported via each app's nuxt.config.ts components.dirs entry — no build step.",
+ "type": "module"
+}
diff --git a/services/platform-api/src/app.module.ts b/services/platform-api/src/app.module.ts
index ed78a27..7629d74 100644
--- a/services/platform-api/src/app.module.ts
+++ b/services/platform-api/src/app.module.ts
@@ -6,7 +6,9 @@ import { AuthModule } from './auth/auth.module.js'
import { FlagsModule } from './flags/flags.module.js'
import { HealthModule } from './health/health.module.js'
import { IngestModule } from './ingest/ingest.module.js'
+import { MeModule } from './me/me.module.js'
import { PartnersModule } from './partners/partners.module.js'
+import { PricesModule } from './prices/prices.module.js'
import { SeedModule } from './seed/seed.module.js'
import { SubscriptionsModule } from './subscriptions/subscriptions.module.js'
import { TenantsModule } from './tenants/tenants.module.js'
@@ -24,7 +26,9 @@ import { UsersModule } from './users/users.module.js'
TenantsModule,
PartnersModule,
UsersModule,
+ MeModule,
SubscriptionsModule,
+ PricesModule,
FlagsModule,
IngestModule,
SeedModule,
diff --git a/services/platform-api/src/audit/audit.service.ts b/services/platform-api/src/audit/audit.service.ts
index 74b9036..4b508a2 100644
--- a/services/platform-api/src/audit/audit.service.ts
+++ b/services/platform-api/src/audit/audit.service.ts
@@ -190,6 +190,27 @@ export class AuditService {
return this.model.find(q).sort({ at: -1, _id: -1 }).limit(limit).exec()
}
+ // Partner-scoped activity feed. Returns events where partnerSlug matches
+ // OR tenantSlug is one of the partner's tenants. Distinct shape from
+ // list() because the OR-across-many-slugs query doesn't fit the
+ // single-tenantSlug filter the operator UI uses.
+ async listForPartner(opts: {
+ partnerSlug: string
+ tenantSlugs: string[]
+ limit?: number
+ before?: Date
+ }): Promise
{
+ const q: FilterQuery = {
+ $or: [
+ { partnerSlug: opts.partnerSlug },
+ ...(opts.tenantSlugs.length > 0 ? [{ tenantSlug: { $in: opts.tenantSlugs } }] : []),
+ ],
+ }
+ if (opts.before) q.at = { $lt: opts.before }
+ const limit = clamp(opts.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT)
+ return this.model.find(q).sort({ at: -1, _id: -1 }).limit(limit).exec()
+ }
+
// ── Chain integrity helpers ────────────────────────────────────────────
// Atomic monotonic counter. findOneAndUpdate with $inc and upsert returns
diff --git a/services/platform-api/src/integrations/authentik.client.ts b/services/platform-api/src/integrations/authentik.client.ts
index ea1f0cd..6006ed5 100644
--- a/services/platform-api/src/integrations/authentik.client.ts
+++ b/services/platform-api/src/integrations/authentik.client.ts
@@ -35,6 +35,12 @@ export class AuthentikClient {
const body = await res.text().catch(() => '')
throw new Error(`Authentik ${init.method ?? 'GET'} ${path} → ${res.status}: ${body.slice(0, 200)}`)
}
+ // 204 No Content (and other empty-body successes) crash res.json().
+ // Endpoints like /core/groups/:id/add_user/ return 204; callers with a
+ // void return type don't care about the payload, so hand back undefined.
+ if (res.status === 204 || res.headers.get('content-length') === '0') {
+ return undefined as T
+ }
return (await res.json()) as T
}
diff --git a/services/platform-api/src/me/me.module.ts b/services/platform-api/src/me/me.module.ts
new file mode 100644
index 0000000..0141c2e
--- /dev/null
+++ b/services/platform-api/src/me/me.module.ts
@@ -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 {}
diff --git a/services/platform-api/src/me/partner-me.controller.ts b/services/platform-api/src/me/partner-me.controller.ts
new file mode 100644
index 0000000..3045789
--- /dev/null
+++ b/services/platform-api/src/me/partner-me.controller.ts
@@ -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,
+ ) {}
+
+ // 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[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= + ?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,
+ })
+ }
+}
diff --git a/services/platform-api/src/partners/partners.controller.ts b/services/platform-api/src/partners/partners.controller.ts
index b327b0e..c8a32b7 100644
--- a/services/platform-api/src/partners/partners.controller.ts
+++ b/services/platform-api/src/partners/partners.controller.ts
@@ -17,6 +17,8 @@ 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 { InvitePartnerUserDto } from '../users/dto/invite-partner-user.dto.js'
+import { UsersService } from '../users/users.service.js'
import { CreatePartnerDto } from './dto/create-partner.dto.js'
import { UpdatePartnerDto } from './dto/update-partner.dto.js'
import { PartnersService } from './partners.service.js'
@@ -42,6 +44,7 @@ function auditActor(
export class PartnersController {
constructor(
private readonly partners: PartnersService,
+ private readonly users: UsersService,
private readonly actorService: ActorService,
) {}
@@ -93,4 +96,31 @@ export class PartnersController {
const user = await this.actorService.resolve(jwt)
await this.partners.terminate(slug, auditActor(user, req))
}
+
+ // Partner-staff team listing. Returns the User docs whose partnerId matches
+ // this partner. The /partners/:slug page's Team section calls this on load.
+ @Get(':slug/users')
+ async listUsers(@Param('slug') slug: string) {
+ const partner = await this.partners.findOneBySlug(slug)
+ return this.users.listPartnerUsers(partner._id)
+ }
+
+ // Invite a new partner-staff user. Resolves slug → partner, delegates to
+ // UsersService.invitePartnerUser which handles Authentik user creation,
+ // group assignment, local User pre-create, and audit recording.
+ @Post(':slug/users')
+ async inviteUser(
+ @Param('slug') slug: string,
+ @Body() dto: InvitePartnerUserDto,
+ @CurrentUser() jwt: AuthentikJwtPayload,
+ @Req() req: Parameters[0],
+ ) {
+ const actor = await this.actorService.resolve(jwt)
+ const partner = await this.partners.findOneBySlug(slug)
+ return this.users.invitePartnerUser(
+ dto,
+ { _id: partner._id, slug: partner.slug },
+ auditActor(actor, req),
+ )
+ }
}
diff --git a/services/platform-api/src/partners/partners.module.ts b/services/platform-api/src/partners/partners.module.ts
index 90004d5..89e019a 100644
--- a/services/platform-api/src/partners/partners.module.ts
+++ b/services/platform-api/src/partners/partners.module.ts
@@ -1,9 +1,10 @@
-import { Module } from '@nestjs/common'
+import { Module, forwardRef } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
+import { UsersModule } from '../users/users.module.js'
import { PartnersController } from './partners.controller.js'
import { PartnersService } from './partners.service.js'
@@ -15,6 +16,10 @@ import { PartnersService } from './partners.service.js'
]),
AuthModule,
AuditModule,
+ // forwardRef defensively — if UsersModule ever grows a dependency back
+ // on PartnersModule (e.g. partner-portal scope guards that need
+ // PartnersService.findOneBySlug), we avoid the circular-dep refactor.
+ forwardRef(() => UsersModule),
],
controllers: [PartnersController],
providers: [PartnersService],
diff --git a/services/platform-api/src/prices/dto/create-price.dto.ts b/services/platform-api/src/prices/dto/create-price.dto.ts
new file mode 100644
index 0000000..57d82b7
--- /dev/null
+++ b/services/platform-api/src/prices/dto/create-price.dto.ts
@@ -0,0 +1,24 @@
+import { Type } from 'class-transformer'
+import { IsBoolean, IsEnum, IsInt, IsOptional, Min, ValidateNested } from 'class-validator'
+
+// Per-currency amount sub-object. Each currency is optional — a plan that
+// isn't sold in EUR just leaves EUR undefined. Stored in minor units.
+class AmountsDto {
+ @IsOptional() @IsInt() @Min(0) DKK?: number
+ @IsOptional() @IsInt() @Min(0) EUR?: number
+ @IsOptional() @IsInt() @Min(0) USD?: number
+}
+
+export class CreatePriceDto {
+ @IsEnum(['mvp', 'pro', 'enterprise'])
+ plan!: 'mvp' | 'pro' | 'enterprise'
+
+ @IsEnum(['monthly', 'quarterly', 'yearly'])
+ cycle!: 'monthly' | 'quarterly' | 'yearly'
+
+ @ValidateNested() @Type(() => AmountsDto)
+ amounts!: AmountsDto
+
+ @IsOptional() @IsBoolean()
+ active?: boolean
+}
diff --git a/services/platform-api/src/prices/dto/update-price.dto.ts b/services/platform-api/src/prices/dto/update-price.dto.ts
new file mode 100644
index 0000000..dccf4c7
--- /dev/null
+++ b/services/platform-api/src/prices/dto/update-price.dto.ts
@@ -0,0 +1,20 @@
+import { Type } from 'class-transformer'
+import { IsBoolean, IsInt, IsOptional, Min, ValidateNested } from 'class-validator'
+
+class AmountsDto {
+ @IsOptional() @IsInt() @Min(0) DKK?: number
+ @IsOptional() @IsInt() @Min(0) EUR?: number
+ @IsOptional() @IsInt() @Min(0) USD?: number
+}
+
+// All fields optional — PATCH a single currency, the active flag, or both.
+// plan + cycle are the row identity and are not editable here (deactivate
+// and create a new row if those need to change, which the partial unique
+// index enforces).
+export class UpdatePriceDto {
+ @IsOptional() @ValidateNested() @Type(() => AmountsDto)
+ amounts?: AmountsDto
+
+ @IsOptional() @IsBoolean()
+ active?: boolean
+}
diff --git a/services/platform-api/src/prices/prices.controller.ts b/services/platform-api/src/prices/prices.controller.ts
new file mode 100644
index 0000000..922b905
--- /dev/null
+++ b/services/platform-api/src/prices/prices.controller.ts
@@ -0,0 +1,38 @@
+import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'
+import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
+import { OperatorGuard } from '../auth/operator.guard.js'
+import { CreatePriceDto } from './dto/create-price.dto.js'
+import { UpdatePriceDto } from './dto/update-price.dto.js'
+import { PricesService } from './prices.service.js'
+
+// All endpoints require an authenticated caller. Read is open to any
+// authenticated JWT (partners + portal users see prices for display);
+// write operations layer the OperatorGuard on top.
+@Controller('prices')
+@UseGuards(JwtAuthGuard)
+export class PricesController {
+ constructor(private readonly prices: PricesService) {}
+
+ @Get()
+ list(@Query('includeInactive') includeInactive?: string) {
+ return this.prices.findAll(includeInactive === 'true')
+ }
+
+ @Post()
+ @UseGuards(OperatorGuard)
+ create(@Body() dto: CreatePriceDto) {
+ return this.prices.create(dto)
+ }
+
+ @Patch(':id')
+ @UseGuards(OperatorGuard)
+ update(@Param('id') id: string, @Body() dto: UpdatePriceDto) {
+ return this.prices.update(id, dto)
+ }
+
+ @Delete(':id')
+ @UseGuards(OperatorGuard)
+ deactivate(@Param('id') id: string) {
+ return this.prices.deactivate(id)
+ }
+}
diff --git a/services/platform-api/src/prices/prices.module.ts b/services/platform-api/src/prices/prices.module.ts
new file mode 100644
index 0000000..07baea9
--- /dev/null
+++ b/services/platform-api/src/prices/prices.module.ts
@@ -0,0 +1,17 @@
+import { Module } from '@nestjs/common'
+import { MongooseModule } from '@nestjs/mongoose'
+import { AuthModule } from '../auth/auth.module.js'
+import { Price, PriceSchema } from '../schemas/price.schema.js'
+import { PricesController } from './prices.controller.js'
+import { PricesService } from './prices.service.js'
+
+@Module({
+ imports: [
+ MongooseModule.forFeature([{ name: Price.name, schema: PriceSchema }]),
+ AuthModule,
+ ],
+ controllers: [PricesController],
+ providers: [PricesService],
+ exports: [PricesService],
+})
+export class PricesModule {}
diff --git a/services/platform-api/src/prices/prices.service.ts b/services/platform-api/src/prices/prices.service.ts
new file mode 100644
index 0000000..ee1eb11
--- /dev/null
+++ b/services/platform-api/src/prices/prices.service.ts
@@ -0,0 +1,154 @@
+import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
+import { InjectModel } from '@nestjs/mongoose'
+import { Model } from 'mongoose'
+import { Price, PriceDocument, type PriceCycle, type PriceCurrency, type PricePlan } from '../schemas/price.schema.js'
+import type { CreatePriceDto } from './dto/create-price.dto.js'
+import type { UpdatePriceDto } from './dto/update-price.dto.js'
+
+@Injectable()
+export class PricesService {
+ private readonly logger = new Logger(PricesService.name)
+ constructor(@InjectModel(Price.name) private readonly priceModel: Model) {}
+
+ async findAll(includeInactive = false): Promise {
+ const filter = includeInactive ? {} : { active: true }
+ return this.priceModel.find(filter).sort({ plan: 1, cycle: 1 }).exec()
+ }
+
+ // Lookup used by tenant provisioning: which active row covers this
+ // (plan, cycle)? Single row per pair now — currency selection happens at
+ // amount-read time, not row-lookup time. Returns null when the row is
+ // missing entirely (e.g. Enterprise that the operator hasn't priced).
+ async findActive(plan: PricePlan, cycle: PriceCycle): Promise {
+ return this.priceModel.findOne({ plan, cycle, active: true }).exec()
+ }
+
+ // Extract a currency's amount from a Price row, returning undefined when
+ // that currency isn't priced. Centralizes the lookup so callers don't
+ // reach into the amounts object directly.
+ amountFor(price: PriceDocument, currency: PriceCurrency): number | undefined {
+ return price.amounts[currency]
+ }
+
+ async create(dto: CreatePriceDto): Promise {
+ try {
+ return await this.priceModel.create({ ...dto, active: dto.active ?? true })
+ } catch (err: unknown) {
+ const e = err as { code?: number; message?: string }
+ if (e.code === 11000) {
+ throw new ConflictException(
+ `Active price for ${dto.plan}/${dto.cycle} already exists — deactivate or PATCH it.`,
+ )
+ }
+ throw err
+ }
+ }
+
+ async update(id: string, dto: UpdatePriceDto): Promise {
+ // Treat amounts as a partial merge so PATCH { amounts: { EUR: 700 } }
+ // updates only EUR without clobbering DKK and USD.
+ const $set: Record = {}
+ if (dto.amounts) {
+ if (dto.amounts.DKK !== undefined) $set['amounts.DKK'] = dto.amounts.DKK
+ if (dto.amounts.EUR !== undefined) $set['amounts.EUR'] = dto.amounts.EUR
+ if (dto.amounts.USD !== undefined) $set['amounts.USD'] = dto.amounts.USD
+ }
+ if (dto.active !== undefined) $set.active = dto.active
+
+ const price = await this.priceModel
+ .findByIdAndUpdate(id, { $set }, { new: true, runValidators: true })
+ .exec()
+ if (!price) throw new NotFoundException(`Price ${id} not found`)
+ return price
+ }
+
+ async deactivate(id: string): Promise {
+ return this.update(id, { active: false })
+ }
+
+ // Bootstrap. Runs in two phases:
+ // 1. Migrate any legacy rows (have top-level `currency` + `perSeatAmount`)
+ // into the new per-row currency-map shape. Preserves operator edits:
+ // if Starter/Monthly was edited to 48 DKK before, the new row keeps
+ // DKK=4800 and only fills in EUR/USD defaults.
+ // 2. Insert any missing default rows (Starter + Business × 3 cycles)
+ // with sensible DKK/EUR/USD round numbers.
+ // Both phases are idempotent — safe to run on every boot.
+ async ensureDefaults(): Promise {
+ await this.migrateLegacy()
+
+ type Defaults = {
+ plan: PricePlan
+ cycle: PriceCycle
+ amounts: { DKK: number; EUR: number; USD: number }
+ }
+ const defaults: Defaults[] = [
+ // Round numbers in each currency, not FX-derived. Operator can edit.
+ { plan: 'mvp', cycle: 'monthly', amounts: { DKK: 4900, EUR: 700, USD: 700 } },
+ { plan: 'mvp', cycle: 'quarterly', amounts: { DKK: 13200, EUR: 1900, USD: 1900 } },
+ { plan: 'mvp', cycle: 'yearly', amounts: { DKK: 48800, EUR: 7000, USD: 7000 } },
+ { plan: 'pro', cycle: 'monthly', amounts: { DKK: 12900, EUR: 1800, USD: 1800 } },
+ { plan: 'pro', cycle: 'quarterly', amounts: { DKK: 34800, EUR: 4900, USD: 4900 } },
+ { plan: 'pro', cycle: 'yearly', amounts: { DKK: 128800, EUR: 17800, USD: 17800 } },
+ ]
+ for (const row of defaults) {
+ const exists = await this.priceModel.findOne({ plan: row.plan, cycle: row.cycle, active: true })
+ if (!exists) {
+ await this.priceModel.create({ ...row, active: true }).catch(() => {
+ // Race / unique-index hit — another boot inserted it; ignore.
+ })
+ }
+ }
+ }
+
+ // Migration: legacy docs had { currency, perSeatAmount } and one row per
+ // (plan, cycle, currency). Collapse them: pick the active row per
+ // (plan, cycle), move perSeatAmount under amounts[currency], and drop
+ // the legacy fields. Inactive duplicates get deleted (their identity has
+ // been folded into the surviving row).
+ private async migrateLegacy(): Promise {
+ const legacy = await this.priceModel
+ .find({ currency: { $exists: true }, perSeatAmount: { $exists: true } } as Record)
+ .lean()
+ .exec()
+ if (legacy.length === 0) return
+
+ this.logger.log(`Migrating ${legacy.length} legacy Price row(s) to per-row currency map`)
+
+ // Group legacy rows by (plan, cycle). Keep the survivor with the most
+ // currencies represented (or just any active one) and merge amounts in.
+ const groups = new Map()
+ for (const row of legacy) {
+ const key = `${row.plan}|${row.cycle}`
+ const list = groups.get(key) ?? []
+ list.push(row)
+ groups.set(key, list)
+ }
+
+ for (const [key, rows] of groups) {
+ // Choose survivor: prefer an active row, then the lowest _id (stable).
+ const survivor = rows.find((r) => (r as { active?: boolean }).active) ?? rows[0]
+ const merged: { DKK?: number; EUR?: number; USD?: number } = {}
+ for (const r of rows) {
+ const c = (r as { currency?: PriceCurrency }).currency
+ const v = (r as { perSeatAmount?: number }).perSeatAmount
+ if (c && typeof v === 'number') merged[c] = v
+ }
+
+ await this.priceModel.updateOne(
+ { _id: survivor._id },
+ {
+ $set: { amounts: merged, active: true },
+ $unset: { currency: '', perSeatAmount: '' },
+ },
+ )
+
+ // Delete the non-survivor rows (their amounts are now in the merged map).
+ const losers = rows.filter((r) => String(r._id) !== String(survivor._id)).map((r) => r._id)
+ if (losers.length > 0) {
+ await this.priceModel.deleteMany({ _id: { $in: losers } })
+ }
+ this.logger.log(`Migrated ${key}: amounts=${JSON.stringify(merged)} (dropped ${losers.length} duplicate row(s))`)
+ }
+ }
+}
diff --git a/services/platform-api/src/schemas/price.schema.ts b/services/platform-api/src/schemas/price.schema.ts
new file mode 100644
index 0000000..cc7bace
--- /dev/null
+++ b/services/platform-api/src/schemas/price.schema.ts
@@ -0,0 +1,55 @@
+import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
+import { HydratedDocument } from 'mongoose'
+
+export type PriceDocument = HydratedDocument
+
+export type PriceCycle = 'monthly' | 'quarterly' | 'yearly'
+export type PriceCurrency = 'DKK' | 'EUR' | 'USD'
+export type PricePlan = 'mvp' | 'pro' | 'enterprise'
+
+// One row per (plan, cycle). Each row holds an independent per-currency
+// amount map so operators can set clean round numbers in every currency
+// (49 DKK / 7 EUR / 7 USD) instead of FX-derived fractions. Currency without
+// a value means "we don't sell this plan/cycle in that currency"; the wizard
+// rejects provisioning if a chosen currency isn't priced.
+//
+// All amounts in MINOR units (øre / cents / cents). 4900 = 49.00, regardless
+// of currency.
+@Schema({ collection: 'prices', timestamps: true })
+export class Price {
+ @Prop({ enum: ['mvp', 'pro', 'enterprise'], required: true, index: true })
+ plan!: PricePlan
+
+ @Prop({ enum: ['monthly', 'quarterly', 'yearly'], required: true, index: true })
+ cycle!: PriceCycle
+
+ @Prop({
+ type: {
+ DKK: { type: Number, min: 0 },
+ EUR: { type: Number, min: 0 },
+ USD: { type: Number, min: 0 },
+ },
+ required: true,
+ default: () => ({}),
+ })
+ amounts!: {
+ DKK?: number
+ EUR?: number
+ USD?: number
+ }
+
+ // Soft-active flag. When the operator changes a row's amounts we mutate
+ // the row in place; deactivation is for cases like end-of-life'd plans
+ // where we want to preserve subs' priceId references without making the
+ // row attach to NEW provisioning runs.
+ @Prop({ type: Boolean, default: true, index: true })
+ active!: boolean
+}
+
+export const PriceSchema = SchemaFactory.createForClass(Price)
+// One active row per (plan, cycle). Partial filter keeps deactivated history
+// rows from blocking the index.
+PriceSchema.index(
+ { plan: 1, cycle: 1 },
+ { unique: true, partialFilterExpression: { active: true } },
+)
diff --git a/services/platform-api/src/schemas/subscription.schema.ts b/services/platform-api/src/schemas/subscription.schema.ts
index dd0aa9f..3d463c3 100644
--- a/services/platform-api/src/schemas/subscription.schema.ts
+++ b/services/platform-api/src/schemas/subscription.schema.ts
@@ -19,6 +19,36 @@ export class Subscription {
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
plan!: 'mvp' | 'pro' | 'enterprise'
+ // Billing cycle. Carried alongside plan so MRR aggregation doesn't have
+ // to walk to the Price doc just to normalise to monthly.
+ @Prop({ enum: ['monthly', 'quarterly', 'yearly'], default: 'monthly' })
+ cycle!: 'monthly' | 'quarterly' | 'yearly'
+
+ // Currency the customer is billed in. Snapshotted from the wizard so a
+ // later catalog edit that adds/removes currencies doesn't change what
+ // this customer pays. MRR aggregation groups by this field.
+ @Prop({ enum: ['DKK', 'EUR', 'USD'], default: 'DKK', index: true })
+ currency!: 'DKK' | 'EUR' | 'USD'
+
+ // The catalog row this sub was provisioned against. Sparse — Enterprise
+ // (custom pricing) and pre-billing-pipeline tenants leave this empty,
+ // which MRR aggregation treats as "0 / custom".
+ @Prop({ type: Types.ObjectId, ref: 'Price', index: true, sparse: true })
+ priceId?: Types.ObjectId
+
+ // Snapshot of the per-seat amount in `currency` at provision time. Lets
+ // MRR aggregation compute without re-reading the Price doc, and keeps
+ // the historical bill stable when the operator edits the catalog later.
+ @Prop({ type: Number, min: 0, default: 0 })
+ perSeatAmount!: number
+
+ // Snapshot of the seat count at provision time. Updated when the
+ // operator/partner changes seats — we don't recompute from Tenant.seats
+ // on each MRR aggregation so a mid-cycle seat change is visible
+ // immediately without waiting for the next renewal.
+ @Prop({ type: Number, min: 0, default: 0 })
+ seats!: number
+
@Prop({
enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete', 'incomplete_expired'],
default: 'trialing',
diff --git a/services/platform-api/src/schemas/tenant.schema.ts b/services/platform-api/src/schemas/tenant.schema.ts
index edc8d07..36b8b71 100644
--- a/services/platform-api/src/schemas/tenant.schema.ts
+++ b/services/platform-api/src/schemas/tenant.schema.ts
@@ -25,6 +25,14 @@ export class Tenant {
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
plan!: TenantPlan
+ // Initial seat count from provisioning. Used for portfolio displays and
+ // (later) MRR calculations. The "used" count comes from User.tenantIds —
+ // not stored here to avoid a denormalized field that drifts on every
+ // user-add/remove. Default 0 so older docs without this field render
+ // as "0 / N" without throwing.
+ @Prop({ type: Number, min: 0, default: 0 })
+ seats!: number
+
// Custom domains attached to this tenant. First entry is the primary host.
@Prop({ type: [String], default: [] })
domains!: string[]
diff --git a/services/platform-api/src/schemas/user.schema.ts b/services/platform-api/src/schemas/user.schema.ts
index 7c12d90..ab96a4d 100644
--- a/services/platform-api/src/schemas/user.schema.ts
+++ b/services/platform-api/src/schemas/user.schema.ts
@@ -15,6 +15,15 @@ export class User {
@Prop({ type: [Types.ObjectId], ref: 'Tenant', default: [], index: true })
tenantIds!: Types.ObjectId[]
+ // Partner this user works FOR (staff/admin at a partner organization).
+ // Optional — most users (tenant members + platform admins) leave this null.
+ // When set, /partners/:slug/users lists this user and future partner-portal
+ // scope guards can verify the JWT-resolved user belongs to the requested
+ // partner. Per-partner role is implicit in being on the list for v1;
+ // refine to per-partner role enum later if partner-portal RBAC needs it.
+ @Prop({ type: Types.ObjectId, ref: 'Partner', index: true })
+ partnerId?: Types.ObjectId
+
@Prop({ required: true, lowercase: true, trim: true, index: true })
email!: string
diff --git a/services/platform-api/src/seed/seed.module.ts b/services/platform-api/src/seed/seed.module.ts
index 06befa0..4f77e5d 100644
--- a/services/platform-api/src/seed/seed.module.ts
+++ b/services/platform-api/src/seed/seed.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { IntegrationsModule } from '../integrations/integrations.module.js'
+import { PricesModule } from '../prices/prices.module.js'
import { Flag, FlagSchema } from '../schemas/flag.schema.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
@@ -10,6 +11,7 @@ import { SeedService } from './seed.service.js'
@Module({
imports: [
IntegrationsModule,
+ PricesModule,
MongooseModule.forFeature([
{ name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema },
diff --git a/services/platform-api/src/seed/seed.service.ts b/services/platform-api/src/seed/seed.service.ts
index ea54216..ce6b3ce 100644
--- a/services/platform-api/src/seed/seed.service.ts
+++ b/services/platform-api/src/seed/seed.service.ts
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AuthentikClient } from '../integrations/authentik.client.js'
+import { PricesService } from '../prices/prices.service.js'
import { Flag, FlagDocument, type FlagState } from '../schemas/flag.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
@@ -62,6 +63,7 @@ export class SeedService implements OnApplicationBootstrap {
@InjectModel(Flag.name) private readonly flagModel: Model,
private readonly authentik: AuthentikClient,
private readonly config: ConfigService,
+ private readonly prices: PricesService,
) {}
async onApplicationBootstrap(): Promise {
@@ -105,6 +107,11 @@ export class SeedService implements OnApplicationBootstrap {
// No user seeded here — UsersController.me() upserts akadmin on first call.
+ // Pricing catalog defaults. Idempotent — operator edits via /pricing
+ // (which marks old rows inactive + inserts new ones) are preserved.
+ await this.prices.ensureDefaults()
+ this.logger.log('Price catalog defaults ensured')
+
// Feature flags. Seeded via $setOnInsert so an operator who later edits a
// flag's state through the UI doesn't get their change reverted on the
// next bootstrap.
diff --git a/services/platform-api/src/tenants/dto/create-tenant.dto.ts b/services/platform-api/src/tenants/dto/create-tenant.dto.ts
index 11ce178..f164ff1 100644
--- a/services/platform-api/src/tenants/dto/create-tenant.dto.ts
+++ b/services/platform-api/src/tenants/dto/create-tenant.dto.ts
@@ -1,11 +1,15 @@
import { Type } from 'class-transformer'
import {
IsArray,
+ IsEmail,
IsEnum,
+ IsInt,
IsOptional,
IsString,
Matches,
+ Max,
MaxLength,
+ Min,
MinLength,
ValidateNested,
} from 'class-validator'
@@ -30,6 +34,33 @@ export class CreateTenantDto {
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
plan?: 'mvp' | 'pro' | 'enterprise'
+ // Billing cycle for the auto-created Subscription. Defaults to monthly
+ // when omitted. Drives which Price catalog row attaches to the sub.
+ @IsOptional() @IsEnum(['monthly', 'quarterly', 'yearly'])
+ cycle?: 'monthly' | 'quarterly' | 'yearly'
+
+ // Currency this customer is billed in. Picked by the partner in the
+ // wizard. The matching Price row must have an amount set for this
+ // currency or the tenant is created without a Subscription (operator
+ // fixes up later).
+ @IsOptional() @IsEnum(['DKK', 'EUR', 'USD'])
+ currency?: 'DKK' | 'EUR' | 'USD'
+
+ // First-admin invite. Optional — when present, the wizard's "First admin"
+ // step has been filled in and the platform creates an Authentik user +
+ // local User doc tied to this tenant as part of provisioning. Failures
+ // here don't roll back the tenant — the operator can re-invite later.
+ @IsOptional() @IsString() @MinLength(1) @MaxLength(200)
+ adminName?: string
+
+ @IsOptional() @IsEmail() @MaxLength(254)
+ adminEmail?: string
+
+ // Initial seat count. Upper bound is generous — partners can provision
+ // bulk-license tenants and the platform doesn't impose a hard cap here.
+ @IsOptional() @IsInt() @Min(0) @Max(10000)
+ seats?: number
+
@IsOptional() @IsArray() @IsString({ each: true })
domains?: string[]
diff --git a/services/platform-api/src/tenants/dto/update-tenant.dto.ts b/services/platform-api/src/tenants/dto/update-tenant.dto.ts
index 8251237..86a644c 100644
--- a/services/platform-api/src/tenants/dto/update-tenant.dto.ts
+++ b/services/platform-api/src/tenants/dto/update-tenant.dto.ts
@@ -1,4 +1,15 @@
-import { IsArray, IsEnum, IsMongoId, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
+import {
+ IsArray,
+ IsEnum,
+ IsInt,
+ IsMongoId,
+ IsOptional,
+ IsString,
+ Max,
+ MaxLength,
+ Min,
+ MinLength,
+} from 'class-validator'
export class UpdateTenantDto {
@IsOptional() @IsString() @MinLength(2) @MaxLength(120)
@@ -10,6 +21,9 @@ export class UpdateTenantDto {
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
plan?: 'mvp' | 'pro' | 'enterprise'
+ @IsOptional() @IsInt() @Min(0) @Max(10000)
+ seats?: number
+
@IsOptional() @IsArray() @IsString({ each: true })
domains?: string[]
diff --git a/services/platform-api/src/tenants/tenants.module.ts b/services/platform-api/src/tenants/tenants.module.ts
index 5c40a67..808c034 100644
--- a/services/platform-api/src/tenants/tenants.module.ts
+++ b/services/platform-api/src/tenants/tenants.module.ts
@@ -3,6 +3,8 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
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 { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { ProvisioningService } from './provisioning.service.js'
@@ -14,10 +16,15 @@ import { TenantsService } from './tenants.service.js'
MongooseModule.forFeature([
{ name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema },
+ // Subscription model accessed directly by TenantsService so a freshly
+ // 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 },
]),
AuthModule,
AuditModule,
IntegrationsModule,
+ PricesModule,
],
controllers: [TenantsController],
providers: [TenantsService, ProvisioningService],
diff --git a/services/platform-api/src/tenants/tenants.service.ts b/services/platform-api/src/tenants/tenants.service.ts
index d45e680..8a84ba3 100644
--- a/services/platform-api/src/tenants/tenants.service.ts
+++ b/services/platform-api/src/tenants/tenants.service.ts
@@ -1,7 +1,9 @@
-import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
+import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
+import { PricesService } from '../prices/prices.service.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 { CreateTenantDto } from './dto/create-tenant.dto.js'
@@ -10,11 +12,15 @@ import { ProvisioningService } from './provisioning.service.js'
@Injectable()
export class TenantsService {
+ private readonly logger = new Logger(TenantsService.name)
+
constructor(
@InjectModel(Tenant.name) private readonly tenantModel: Model,
@InjectModel(User.name) private readonly userModel: Model,
+ @InjectModel(Subscription.name) private readonly subModel: Model,
private readonly provisioning: ProvisioningService,
private readonly audit: AuditService,
+ private readonly prices: PricesService,
) {}
async listUsersForTenant(slug: string): Promise {
@@ -36,10 +42,40 @@ export class TenantsService {
resourceId: String(tenant._id),
resourceName: tenant.name,
tenantSlug: tenant.slug,
- metadata: { plan: tenant.plan, domains: tenant.domains },
+ metadata: { plan: tenant.plan, domains: tenant.domains, seats: tenant.seats },
},
actor,
)
+
+ // Spin up the matching Subscription. Looks up the active Price row for
+ // (plan, cycle) — single row regardless of currency now — then reads
+ // amounts[currency] for the snapshot. If the chosen currency isn't
+ // priced, we still create the sub but with perSeatAmount=0 so MRR
+ // reports it as "Custom" rather than a misleading non-zero value.
+ try {
+ const plan = tenant.plan ?? 'mvp'
+ const cycle = dto.cycle ?? 'monthly'
+ const currency = dto.currency ?? 'DKK'
+ const price = await this.prices.findActive(plan, cycle)
+ const perSeatAmount = price ? (this.prices.amountFor(price, currency) ?? 0) : 0
+ await this.subModel.create({
+ tenantId: tenant._id,
+ plan,
+ cycle,
+ currency,
+ priceId: price?._id,
+ perSeatAmount,
+ seats: tenant.seats ?? 0,
+ status: 'active',
+ })
+ } catch (err) {
+ this.logger.warn(
+ `Subscription auto-create failed for tenant ${tenant.slug}: ${
+ err instanceof Error ? err.message : String(err)
+ } — tenant created, MRR will read as 0 until the sub is added.`,
+ )
+ }
+
// Provision external resources best-effort. Errors are recorded on the doc;
// the caller can re-POST or call /tenants/:slug/reconcile to retry.
return this.provisioning.reconcile(tenant)
diff --git a/services/platform-api/src/users/dto/invite-partner-user.dto.ts b/services/platform-api/src/users/dto/invite-partner-user.dto.ts
new file mode 100644
index 0000000..85ed40a
--- /dev/null
+++ b/services/platform-api/src/users/dto/invite-partner-user.dto.ts
@@ -0,0 +1,16 @@
+import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator'
+
+// Operator-only: invite someone at a partner organization. Mirrors
+// InviteOperatorDto in shape — body is just identity. The partner is
+// resolved from the URL slug (POST /partners/:slug/users) so it never
+// appears in the body. The created user is added to the
+// dezky-partner-staff Authentik group and has User.partnerId set to the
+// partner's _id; future partner-portal scope guards use that to allow
+// access only to the partner's own data.
+export class InvitePartnerUserDto {
+ @IsString() @MinLength(2) @MaxLength(120)
+ name!: string
+
+ @IsEmail() @MaxLength(254)
+ email!: string
+}
diff --git a/services/platform-api/src/users/users.controller.ts b/services/platform-api/src/users/users.controller.ts
index 6c4271c..1578dfc 100644
--- a/services/platform-api/src/users/users.controller.ts
+++ b/services/platform-api/src/users/users.controller.ts
@@ -45,9 +45,11 @@ export class UsersController {
// The signed-in user's own profile — bootstraps the user record on first call,
// and syncs name/email/tenants/platformAdmin from the JWT on every subsequent call.
+ // Adds a `partner` field when User.partnerId is set so the portal can decide
+ // whether to render the partner-admin surface or the end-user surface.
@Get('me')
async me(@CurrentUser() jwt: AuthentikJwtPayload) {
- return this.users.upsertFromAuthentik({
+ return this.users.meWithPartner({
subject: jwt.sub,
email: jwt.email ?? jwt.preferred_username ?? jwt.sub,
name: jwt.name ?? jwt.preferred_username ?? jwt.email ?? jwt.sub,
@@ -56,6 +58,9 @@ export class UsersController {
})
}
+ // Partner-scoped endpoints live in PartnerMeController under /me/partner.
+ // Identity endpoints (above) stay here.
+
@Post()
async create(@Body() dto: CreateUserDto, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
diff --git a/services/platform-api/src/users/users.module.ts b/services/platform-api/src/users/users.module.ts
index 78cff6f..9c88f6e 100644
--- a/services/platform-api/src/users/users.module.ts
+++ b/services/platform-api/src/users/users.module.ts
@@ -3,6 +3,9 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
+import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
+import { Price, PriceSchema } from '../schemas/price.schema.js'
+import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
@@ -14,6 +17,16 @@ import { UsersService } from './users.service.js'
MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: Tenant.name, schema: TenantSchema },
+ // Partner model registered directly (instead of importing PartnersModule)
+ // to avoid the circular import — PartnersModule already imports
+ // UsersModule for the invitePartnerUser delegation.
+ { name: Partner.name, schema: PartnerSchema },
+ // Subscription + Price are read by partnerMrr() — MRR aggregation
+ // runs entirely in this service rather than chaining into
+ // SubscriptionsModule, since we want one direct query path that's
+ // easy to extend (prorating, multi-currency) later.
+ { name: Subscription.name, schema: SubscriptionSchema },
+ { name: Price.name, schema: PriceSchema },
]),
AuthModule,
AuditModule,
diff --git a/services/platform-api/src/users/users.service.ts b/services/platform-api/src/users/users.service.ts
index d476878..0b1d68b 100644
--- a/services/platform-api/src/users/users.service.ts
+++ b/services/platform-api/src/users/users.service.ts
@@ -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,
@InjectModel(Tenant.name) private readonly tenantModel: Model,
+ @InjectModel(Partner.name) private readonly partnerModel: Model,
+ @InjectModel(Subscription.name) private readonly subModel: Model,
+ @InjectModel(Price.name) private readonly priceModel: Model,
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 {
+ 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 {
+ 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> {
+ 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 {
+ 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()
+ 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 {
+ 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)
+}