- Write the brand name as lowercase "dezky" across all user-facing copy
(the legal entity "Dezky ApS" stays capitalised).
- Change the general contact email kontakt@dezky.eu -> info@dezky.eu.
- Footer tagline now "European" rather than only Danish business.
- Compare table header logo uses the on-dark treatment (signal-green
squircle + carbon d) instead of a low-contrast green d on a light chip.
- /demo: book-a-demo page with a what-to-expect column + a form that
composes a prefilled email to info@dezky.eu (interim, no backend); built
to swap for a self-hosted scheduler later. Wire every "Book a demo" CTA
(nav, hero, pricing, the previously-dead final-CTA button, and the
contact/partners/migration/coming-soon CTAs) to /demo.
- /status: manually-maintained system-status page (overall banner,
per-service rows, incident history). Live modules operational; Video/Chat
marked coming soon.
- Roadmap: expand the board (5 items/column) + a "the bigger picture"
themes grid + a "suggest a feature" CTA + a directional-timelines note.
- Contact: purpose-specific channels (info@ / legal@ / privacy@), a
response-time note, and a company + "see it live" demo block.
- Drop /status from the [slug].vue stub map; tidy now-unused imports.
Extend /about with the "why": a "the problem" section (jurisdiction vs
geography, creeping prices, lock-in, vendor sprawl) as a 2x2 pain grid, and
a "we're founders ourselves" section. Both bilingual and responsive.
Also point the footer partner link to /partners (was the homepage
#whitelabel section).
Replace the /sla and /cookies stubs with real pages (da + en), matching
the DPA/terms/privacy layout. Drop both from the [slug].vue stub map.
- SLA: 99% uptime target framed as a transparency-backed commitment (no
public service credits); contractual credits offered only via Enterprise
agreements. Support first-response targets table; status-page reference.
- Cookie policy: strictly-necessary + language preference only, cookie-free
analytics (Umami), no third-party tracking, "no banner needed"; cookie
table; final (v1.0), no draft banner.
useLang now seeds from a first-party `dezky-lang` cookie on SSR init and
toggleLang writes it (12 months, SameSite=Lax). The chosen language now
survives reloads/return visits (fixes the reset-to-Danish quirk) and makes
the cookie-policy entry accurate.
Replace the /terms stub with a real Servicevilkår page (da + en),
mirroring the DPA/privacy layout and draft / legal-review banner.
- 12 clauses tailored to the system: the service, per-user accounts,
acceptable use, customer data (EU-hosted, DPA ref, export/no lock-in),
pricing & payment (49 kr/user/mo excl. VAT), term/renewal/cancellation,
uptime & SLA ref, IP, liability cap (last 12 months' fees), changes,
Danish law / venue Esbjerg
- Drop 'terms' from the [slug].vue stub map so the real page wins
- Contact: legal@dezky.eu
Still a 0.1 draft pending legal review (flagged on the page).
Make the marketing site mobile-friendly across every page and section.
Desktop appearance is unchanged; all breakpoint logic targets <=768px.
- Fluid section padding via clamp(); equal grids use auto-fit/minmax,
asymmetric grids stack to one column via scoped-CSS media queries
- Nav: real hamburger menu on mobile (links, lang toggle, login, CTA)
- ProductMockup: scales the whole dashboard to fit (zoom) instead of
reflowing its internals into a tall stack
- Lower oversized heading clamp() minimums so titles no longer overflow
at ~390px (hero, page headers, final CTA, brand cover/chapter)
- HowItWorks: row-gap when steps stack so node markers clear the text
- Compare + partners tables: stacked rows now label each value with its
column (Dezky vs hyperscaler / CSP) instead of an ambiguous header
- Footer columns, tiers, calculator and tables stack cleanly on mobile
Replace the /brand stub with the full Brand Guide from the Claude Design
handoff (cover, logo, color, typography, voice, applications), rendered inside
the site layout, English-only, with illustrative copy adapted to Dezky's real
EU-sovereign voice. Extend NodeMark with a `variant` (donut/solid/outline) and
NodeLockup with a separate `wordmark` colour so the mark reads correctly on
dark surfaces. Drops the old brand stub copy.
Set TMPDIR=/tmp in the dev script so the Nuxt vite-node Unix socket path stays
under macOS's 104-char limit (fixes "Failed to restrict vite-node socket
permissions" on local runs; harmless in the Linux container). Also fix the
package.json description: dezky.com → dezky.eu.
The /partners margin calculator now derives margin from the user count using
progressive brackets (like tax brackets): first 500 users at 15%, 501–1000 at
30%, 1001+ at 40%, off the 49 kr/user/mo list price. Replaces the manual margin
slider with a live per-bracket breakdown. Rates are read from the tier copy;
tier thresholds aligned to 501 / 1.001 so the cards and calculator agree.
Add the /partners page rendering the partner pitch: benefits, an interactive
margin calculator (seats × margin → monthly/annual, off the 49 kr list price),
a reseller-facing "CSP vs Dezky" comparison, partner tiers, a 3-step "get
started", and a partner FAQ. Wire the section-06 "see the partner program"
button to it, and align the whitelabel margin bullet to 15–40%.
Wire every footer link to a real route. Adds a shared `page` layout (Nav +
content + Footer), reusable PageHeader/ComingSoon components, six content pages
(about, contact, brand, roadmap, changelog, migration), and a dynamic [slug]
catch-all for the not-yet-built pages — unknown slugs 404, legal slugs get a
distinct "contact us" body.
Footer links repointed from dead "#" to real paths; section anchors ("/#suite")
smooth-scroll on the homepage and route home + scroll from a sub-page; logo
links home. Page copy (da + en) added under COPY.pages.
Hero headline was a broken comma-splice ("Den produktivitetssuite, dine data
bliver i Danmark med."); replace with a clean two-sentence line in both
languages:
da: "Din digitale arbejdsplads. Data der bliver i EU."
en: "Your digital workplace. Data that stays in the EU."
Also move all brand references off the unowned dezky.com to dezky.eu
(status page, app dashboard mockup, config/comments). External domains
(zulip.com, Google Fonts) are left untouched.
Add a "Configure git remote" step that points origin at the Gitea host
(git@git.lastcloud.io) and pins the host to port 22222 in ~/.ssh/config so
git doesn't default to port 22 and get rejected by the agent offering too many
keys. Idempotent: reuses existing config on re-run. Also adds git to the
prerequisite checks and renumbers the steps to 1-7.
Partner demo cards in section 06 were hardcoded Danish strings, so they
stayed Danish in EN mode. Move name + subtitle into COPY.whitelabel.partners
for both languages and render them via a mapped computed; per-card accent and
the placeholder style remain presentational config in the component.
Also harden PartnerCard's avatar-initial against an empty name to satisfy
noUncheckedIndexedAccess.
New standalone apps/website (Nuxt 4) serving the public marketing site at
dezky.local / www.dezky.local. The customer portal moves off the root domain
to app.dezky.local only.
Landing page ported from the Dezky design handoff: light theme, Danish
default, hero variant A, with a working da/en toggle. Self-contained colour
system threaded through components (utils/landingTokens.ts), full bilingual
copy (utils/landingCopy.ts), and shared state (composables/useLanding.ts).
Sections live under components/landing/* with the Node logo under
components/brand/*.
Wired into docker-compose (website service, volume, Traefik labels, network
aliases) and bootstrap.sh (hosts list + service URLs).
Wire the mail/identity stack to real Stalwart/Authentik/OCIS provisioning,
replacing the mocked Domains and Users pages.
Domains (customer-admin):
- StalwartClient: real JMAP management (v0.16 dropped REST) — create/list/delete
email domains via x:Domain at the internal http://stalwart:8080 listener;
DKIM auto-generated; the records to publish are read from the domain's
dnsZoneFile. Gated by STALWART_PROVISIONING_ENABLED.
- New Domain collection + DomainsModule: add/list/recheck/set-DMARC/remove,
tenant-membership-gated and audited.
- DnsVerifierService: verifies MX/SPF/DKIM/DMARC/ownership against a public
resolver (1.1.1.1/8.8.8.8) and diffs them against the expected records.
- Remove is guarded: refuses while accounts/aliases/mailing lists still use the
domain (via Stalwart referential integrity).
- Domains page + add wizard on real data; sidebar badge counts domains needing
attention.
Users & groups (customer-admin):
- Create a member provisioned across Authentik SSO, a Stalwart mailbox on the
tenant's primary domain, and OCIS — returning a one-time password.
- Lifecycle: suspend/resume (Authentik is_active + freeze the mailbox via
account permissions, original password preserved), force-logout (terminate
sessions, filtered client-side so it can never end other users' sessions),
reset password (new one-time password on SSO + mailbox), and remove (tear down
mailbox + SSO identity + OCIS + doc; mailbox-in-use aware for multi-tenant
users). Self-suspend / self-force-logout are blocked.
Infra: point platform-api at the internal Stalwart listener; document the new
STALWART_/provisioning vars in .env.example.
GET /tenants/:slug/users now returns a tenant-scoped `tenantRole`
(resolved server-side via roleForTenant), and the operator tenant page
displays it instead of the global `role` — so a user who is admin here
but member elsewhere reads correctly in this tenant's context. The
global `role` field is kept intact for other consumers.
A user who is admin in one tenant but member in another must read
'admin' for this tenant — use roleForTenant() rather than the global
u.role fallback when building the tenant users list.
The Storage page + endpoint landed earlier but had no working OCIS
backend credential. OCIS has no service-account/client-credentials grant
and trusts a single issuer, and basic auth resolves no user in our
external-IdP setup — so authenticate OcisClient via an OIDC
refresh-token bootstrap instead:
- One-time headless login of svc-platform-api against the ocis provider
(public client ocis-web, issuer .../o/ocis/) yields a refresh token,
persisted in Mongo (ocis_credentials) and rotated on every use.
- OcisClient mints access tokens with the refresh_token grant; the
service user holds the OCIS admin role (OCIS_ADMIN_USER_ID) so
libregraph ListAllDrives works.
- scripts/bootstrap-ocis.mjs re-runs the bootstrap if the token lapses.
- Dashboard Plan card gains a storage capacity bar beside seats;
hidden when storage is unavailable.
- compose + .env.example: OCIS service OIDC env and admin user id.
- docs/NEXT-STEPS: document the mechanism and the dead-end alternatives.
Security & audit (admin)
- Audit log: real, tenant-scoped — widened GET /tenants/:slug/audit with
q/action/outcome/actorEmail/since/before; UI gains search, outcome + time
filters, action chips, cursor pagination, and client-side CSV export.
- Security policy: new tenant.securityPolicy (mfaMode, session idle/absolute,
allowedCountries, ipAllowlist) + PATCH /tenants/:slug/security-policy
(membership-gated, audited). Editable, labelled by enforcement status.
- MFA: live enrollment overview via GET /tenants/:slug/mfa-status
(Authentik countAuthenticators per member).
- SSO apps (Dezky as IdP): real Authentik OIDC provider + application CRUD,
scoped to the tenant group. New AuthentikClient methods (provider/app/binding
+ flow/key/scope discovery), TenantSsoApp schema, TenantSsoService (rollback
on partial failure; client secret never stored), GET/POST/DELETE
/tenants/:slug/sso-apps. Validated end-to-end against live Authentik.
- Deferred: shared-flow MFA/geo/session enforcement (global auth-flow blast
radius) — to be done as its own reviewed change.
Bundled in-progress work that shares the same files (kept together so the tree
stays green):
- Storage page: StorageService + GET /tenants/:slug/storage (OCIS-backed),
storage.get proxy, storage.vue.
- Per-tenant roles: User.tenantRoles + MeProfile.tenantRoles plumbing.
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.
Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.
Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.
Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
with YIQ auto-contrast (readableOn util).
Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
drag-select-to-close).
Editing a catalog amount now propagates beyond MongoDB. Stripe Prices are
immutable, so each changed currency mints a fresh Stripe Price at the new
amount, overwrites the cached stripePriceIds[currency] (which also fixes the
stale-price bug for new subscriptions), and repoints every live subscription
on that (row, currency) onto it with proration_behavior 'none' — the new
amount takes effect at the customer's next billing cycle, no mid-cycle charge.
The per-seat snapshot is refreshed so MRR reflects the go-forward rate.
Before committing the edit, the operator sees a warning with the affected
customer count, driven by a new GET /prices/:id/impact endpoint. Per-sub
failures are logged, never fatal; Stripe-disabled rows still re-snapshot.
A partner or tenant admin could complete the dezky-operator OIDC flow and
land on the operator portal. The platform-api OperatorGuard already 403s
their data, but the login/UI layer had no authorization check at all — the
only gate was a manual Authentik UI setting with nothing in git enforcing it.
Close it with defense-in-depth across three independent layers:
1. IdP — operator-application.yaml blueprint binds an
ak_is_group_member("dezky-platform-admins") policy to the dezky-operator
app, so Authentik denies the OIDC flow for non-admins. The blueprint also
provisions the provider + application (state: created, so a fresh env is
built from code while an existing hand-made provider is left untouched).
Wire OPERATOR_OIDC_* into both authentik containers and mount the
blueprints dir on the worker (it applies blueprints, and previously lacked
the mount).
2. Operator app — require-platform-admin.global.ts requires platformAdmin and
routes a non-admin to not-authorized.vue, which triggers a full sign-out
(local + Authentik IdP) for shared-workstation safety. Fails open on a
transient /api/me error by design, to avoid mass-signout on platform-api
restarts; layers 1 and 3 contain the exposure.
3. platform-api — OperatorGuard (unchanged) requires dezky-operator audience
plus platformAdmin resolved from the DB on every request.
Also harden the partner surface: it shares the dezky-portal client with tenant
users so it has no IdP gate, and its /partner/* route middleware now fails
CLOSED when identity can't be confirmed.
Docs (AUTHENTIK-SETUP.md) and .env.example updated; the operator client secret
must be set before first boot since the blueprint now consumes it.
pricing.vue nested <PageHeader> inside .stage, so the header inherited .stage's 40px horizontal + 24px top padding on top of its own — over-indenting the title and pushing it down vs every other operator page. Move PageHeader to the top level with .stage wrapping only the content below, matching index/tenants/audit/infrastructure/billing/reports.
Replace the last two holdouts: the dashboard partner-identity fallback now uses the real useMe().partner name, and the decorative MRR sparkline (dashboard + reports) moves to a generated useMrrTrendline() — deterministic, clearly placeholder-only, until a daily MRR-snapshot job exists. Removes the dead sparkLast/sparkTrendPct vars. The data/customers.ts fixture module is now fully deleted; the partner portal carries no mock business data.
Migrate the partner-mode customer switcher, in-customer banner, sidebar tile and the team invite/teammate panels off the data/customers fixture onto the real /api/partner/tenants list (shared key, gated to partner-staff so the global shell doesn't 403 for other users). Active customer resolves by tenant _id (the key the customers page already passes to partnerMode.enter); partner-identity labels now use the real partner name from useMe. Removes the now-unused customers + CustomerOrg-list fixture export and the dead setCustomer helper. Verified in UI: switcher + enter/exit show real Baslund Test / Baslund Research ApS.
Drop partnerTeam, partnerInvoices, partnerActivity and partnerAudit from data/customers.ts — these had no remaining importers now that team, billing, dashboard activity and audit run on real data. The still-used partner / customers / partnerMrrSparkline exports (partner-mode customer switcher + decorative sparkline) stay until those consumers are migrated.
Add BillingService.generatePayouts: idempotent per-partner/month/currency snapshot of gross MRR x marginPct into Payout rows (never rewrites a paid row), plus platformPayouts(). A PayoutWorker generates the current month daily (and on boot; PAYOUTS_AUTOGEN=false to disable). Operator endpoints GET /billing/payouts + POST /billing/payouts/generate, an operator payouts ledger table with a Generate button, and the proxy routes. The partner Payouts tab now shows real data.
Tenants always belong to a partner, so the operator must not create orphan (partnerless) tenants. Remove the NewTenantModal component, the POST /api/tenants proxy route, and the New-tenant button/modal from the tenants page. Tenant creation now happens only via the partner portal wizard (which forces partnerId).
Wire the Stripe lifecycle into TenantsService (best-effort, gated on stripe.enabled): on create open a Stripe customer and, for a priced plan with seats >= 1, lazily create a Stripe Product+Price (persisted to Price.stripePriceIds[currency]) and a send_invoice subscription; mirror seat changes to the subscription quantity; pause/resume on suspend/resume; cancel on delete. A Stripe failure never blocks the tenant — the local Subscription stays the source of truth for derived MRR.
Pass STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and BILLING_STRIPE_ENABLED into the platform-api service from the gitignored .env. Defaults keep billing dark (derived data) when unset.
Add a lazy/guarded Stripe client (boots without keys), Invoice/Payout schemas, per-currency Price.stripePriceIds, and a BillingService deriving partner/platform summaries, invoices and a partner-cut payout ledger. Partner and operator billing controllers plus a signature-verified Stripe webhook (Fastify raw body). Frontend: partner and operator billing pages and the operator tenant billing/audit tabs on real data. Gated behind new_billing_engine and BILLING_STRIPE_ENABLED; live money paths stay off until keys are set.
Partner reports — health cohorts, revenue-by-plan, top customers, signup/churn cohorts, plus saved custom reports (create/list/delete). Operator platform-wide reports (MRR, revenue by plan, top tenants, growth). Replaces the reports fixtures in both apps.
Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation.
Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save.
Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
Move partner domain types out of data/customers.ts into types/partner.ts so the fixture data exports can be removed later without breaking type imports. Add usePartnerTenants / usePartnerMrr composables wrapping the shared-key partner fetches.
Upgrade both Nuxt apps to Nuxt 4.4.6 (vue-tsc 3, TypeScript 5.6, undici 7) and add a root tsconfig.json to each app. Fix the strict-null / noUncheckedIndexedAccess errors surfaced by Nuxt 4's stricter generated tsconfig and vue-tsc 3. Drop the nuxt-oidc-auth pnpm patch (Nuxt 4 fixes the prepare:types crash natively).
Wires the previously-dead 'New tenant' button on /tenants to a modal
that collects slug + name + plan + optional primary domain, POSTs to
the existing platform-api /tenants endpoint via a new operator proxy,
and navigates into the freshly-created tenant detail page. Slug
auto-derives from the name until the operator types in the slug field
themselves. Billing details and provisioning are still done from the
tenant detail page after creation — this modal is the minimum that
backend validators will accept.
Every page header's Refresh button rendered a downward chevron because
the icon set had no refresh glyph. Added a circular-arrow 'refresh'
icon to UiIcon and pointed all seven Refresh buttons (Overview,
Tenants, Partners, Users, Operator team, Audit, Infrastructure) at it.
@IsOptional() only skips validation for null/undefined; an empty string
still trips @IsEmail(). When the operator UI saves billingInfo with a
blank contactEmail, the request 400'd with 'must be an email'. Coerce
'' → undefined on every @IsEmail-decorated field via a shared
@Transform so blank inputs round-trip cleanly.
partner.updated events previously recorded only the field names that
changed (metadata.changes). Now they record metadata.diff — a
{ field: { from, to } } map — by reading the partner before the
findOneAndUpdate and comparing serialized values. Only fields that
actually differ make it into the diff, so a save-without-changes
records an empty diff instead of every DTO key.
The operator audit row's expanded panel renders the diff as a small
inline table (field · from → to). Older audit rows that still carry
metadata.changes fall back to the original chip layout so historical
events stay readable.
Rows with a non-empty metadata object now show a chevron and become
clickable. Clicking expands a detail row underneath that lists every
metadata field — partner.updated/tenant.updated render their 'changes'
array as a row of mono chips, everything else falls back to a generic
key/value layout. Toggling is per-row, so the operator can open
several at once when comparing actions.
inline-grid with minmax(96px, auto) gave the 4-option control a fixed
~390px intrinsic width; when the contract card's right column was
narrower, 'terminated' spilled out. Switch to a full-width grid with
minmax(0, 1fr) so columns share space equally, and let button labels
ellipsize when the cell shrinks below their preferred width.
Toggle the partner detail cards from read-only to editable in place. Edit
button in the PageHeader flips to Cancel + Save changes; cards expose
text inputs for name/domain/contact/billing, a 4-option segmented
control for status, and a 0–100 range slider for marginPct. Save sends
a PATCH diff (only fields that actually changed), refreshes the page
data, and exits edit mode. Cancel with unsaved changes confirms first.
Also tightens audit metadata: previously `Object.keys(dto)` on the
ValidationPipe-instantiated DTO listed every @IsOptional() field, even
when the request body didn't touch them. The partner.updated audit
event now records only the keys the operator actually sent.
Two related fixes that together close the "no recovery flow" gap behind
the invite-operator feature.
1. SeedService now provisions an Authentik recovery flow on every boot.
Without this, /core/users/{pk}/recovery/ returns 400 "No recovery flow
set." and our invite endpoint silently falls back to setting a plaintext
temp password — operationally fine in dev but not appropriate for prod.
ensureRecoveryFlow() (in seed.service.ts):
- Check if a flow with designation='recovery' already exists → no-op
- Otherwise create one with slug='default-dezky-recovery'
(designation='recovery', authentication='none' so the link token
is the only auth needed)
- Bind three default Authentik stages to it in order:
10: default-authentication-identification (auto-skipped when the
recovery token already pins a user; lets the flow also work
for self-service "forgot password" entry)
20: default-password-change-prompt
30: default-password-change-write
- PATCH the default brand's flow_recovery to point at the new flow
- Wrapped in .catch(warn) so an Authentik blip during boot doesn't
crash platform-api — next restart retries.
AuthentikClient additions:
- findRecoveryFlow(), getDefaultBrand(), findStageByName(),
createFlow(), bindStageToFlow(), setBrandRecoveryFlow().
IntegrationsModule pulled into SeedModule so SeedService can use
AuthentikClient.
2. Temp-password fallback path now marks the password expired so
Authentik forces a change on next login. Closes the window where an
operator's plaintext share could outlive the new user's first session.
AuthentikClient.markPasswordExpired(userPk):
- GET user → merge attributes.passwordExpired=true +
passwordExpiredAt=now → PATCH back
- Read-modify-write because Authentik PATCH replaces nested objects
and we don't want to clobber other attributes
UsersService.inviteOperator() calls it on the fallback branch only —
the recovery-link path doesn't need it (clicking the link sets a
fresh password through the flow anyway).
Verified end-to-end:
- Boot → recovery flow auto-provisioned with three correctly-ordered
stage bindings, default brand patched to flow_recovery=<new pk>.
- Re-invite test user → modal now shows a single recovery link
starting with https://auth.dezky.local/if/flow/default-dezky-
recovery/?flow_token=... (no temp password fallback).
- Operator-team list still updates to include the new user
immediately via the pre-created local User doc.
Known follow-ups:
- Enforce MFA enrollment in the recovery flow (add an authenticator
stage). Deferred — locks users out if they lose the second factor
on day one. Better to fire MFA from a separate "MFA required" stage
on subsequent logins for platform admins.
- Outbound SMTP (Phase 5/6) so Authentik emails the recovery link
directly and the modal hides it.