Commit Graph

16 Commits

Author SHA1 Message Date
Ronni Baslund 98e49bfe34 feat(admin/users): editable member drawer + mailbox & ownership management
ci / typecheck (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / typecheck (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / typecheck (map[dir:apps/website name:website]) (push) Has been cancelled
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / test (push) Has been cancelled
Rebuild the /admin/users detail drawer from a read-only profile into an
editable, Office 365-style panel with four sections:

- Username & mail: read-only primary for mailbox users; editable sign-in
  (Authentik-only) for mailbox-less identities; "Create mailbox" provisions
  a Stalwart inbox for an external-login admin
- Aliases: list/add/remove mailbox aliases (Stalwart), domain-scoped
- Role: member/admin toggle with a primary-account lock (owner, mailbox-less
  bootstrap admin, self) and a last-admin guard
- Contact information: display name, first/last name, phone, alternative
  email — mirrored best-effort to Authentik attributes + mailbox name

Ownership transfer: "Make owner" (row menu + drawer) plus an owner-side
"Transfer ownership" picker, gated to tenant admins / platform admins so a
departed owner can be replaced; promotes the target and demotes the prior
owner to admin.

Backend (platform-api): contact fields on User; AuthentikClient.updateUser;
StalwartClient.setMailboxName; UsersService updateTenantMember,
changeMemberPrimaryEmail, list/add/removeMemberAlias, createMailboxForMember,
transferOwnership; new DTOs and tenant-member routes. All mutations audited.

Portal: Nuxt proxies for the new endpoints + extended TenantUserDoc.
2026-06-07 10:34:53 +02:00
Ronni Baslund 5ed3d2bc5f feat(scheduling): dezky Scheduling — Calendly-style booking on Stalwart calendars
First-party booking system on top of Stalwart calendars (no third-party
scheduling dependency). Hosts expose public booking pages; visitors pick a
slot computed from the host's live Stalwart free/busy, and confirming writes
the event to the host's calendar and sends a dezky-branded confirmation with
an .ics.

platform-api (services/platform-api/src/scheduling):
- Schemas: Host, StalwartCredential (AES-256-GCM at rest), AvailabilitySchedule,
  EventType, Booking, SlotLock (unique (hostId,startUtc) + TTL).
- StalwartCalendarModule: JMAP gateway (free/busy via Principal/getAvailability,
  event create/delete, scheduleAgent=client) + on-behalf app-password
  provisioning. CredentialCipher for at-rest encryption.
- DST-correct slot engine (Luxon) with unit tests; two-layer double-booking
  guard (atomic SlotLock + live free/busy re-check).
- Booking confirm/cancel/reschedule, branded email + .ics via JMAP submission,
  self-service manage tokens. /api/v1 public + tenant-gated admin routes,
  per-IP rate limiting.

apps/booking: standalone public, whitelabel booking app (booking.dezky.eu) —
path-based tenant resolution, per-tenant brand colour, booking + manage flows.

apps/portal: admin scheduling page (hosts, event types, availability, bookings
with edit/delete + admin cancel/reschedule) and proxy routes.

infra: booking dev service in docker-compose; scheduling env vars.
2026-06-07 00:17:36 +02:00
Ronni Baslund aee8f13899 feat(mail): tenant alias and distribution-list management via Stalwart
Customer-admin Mail settings backed by Stalwart JMAP: per-tenant aliases
(extra addresses routing to a mailbox) and distribution lists (one address
fanning out to many recipients). Adds StalwartClient x:Alias/x:MailingList
methods, a tenant-scoped MailController/MailService, the portal Mail settings
page and its proxy routes, and the mailboxAddress field on TenantUserDoc.
Removes the old mock mail data now that the page reads live data.
2026-06-07 00:16:30 +02:00
Ronni Baslund 47eb9502f8 feat(platform): real email domains, mailboxes & member lifecycle
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.
2026-06-01 21:19:42 +02:00
Ronni Baslund f8618b2bbc feat(portal): real OCIS storage data via refresh-token service auth
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.
2026-05-31 21:29:17 +02:00
Ronni Baslund 559348f6bc feat(portal): real Security & audit page (+ bundled Storage / per-tenant-roles WIP)
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.
2026-05-31 17:20:36 +02:00
Ronni Baslund 3288fde693 feat(portal): customer-admin surface on real data + Stripe billing + session resilience
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
  leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
  launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.

Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
  invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
  and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
  honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.

Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.

Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
  name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
  with YIQ auto-contrast (readableOn util).

Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
  pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
  drag-select-to-close).
2026-05-31 00:19:34 +02:00
Ronni Baslund db26dafc64 feat(billing): sync catalog price edits to Stripe + re-price live customers
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.
2026-05-30 16:13:15 +02:00
Ronni Baslund 69197e11ae feat(billing): provision Stripe customer + subscription on tenant create
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.
2026-05-30 08:29:34 +02:00
Ronni Baslund 0e1d2fb0d1 feat(billing): Stripe-backed billing engine (dark-launched)
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.
2026-05-30 08:03:23 +02:00
Ronni Baslund 89691626f4 feat: partner enrichment, mutations, settings & branding + operator quick-wins
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.
2026-05-30 08:03:07 +02:00
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
2026-05-28 20:00:33 +02:00
Ronni Baslund 0299328175 feat(authentik): auto-wire recovery flow on bootstrap + expire fallback temp passwords
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.
2026-05-24 21:46:35 +02:00
Ronni Baslund 9a97945565 feat(operator): invite operator → creates user in Authentik
New "Invite operator" button + modal on /operator-team. Replaces the
bounce-to-Authentik flow with an inline invite that creates the user via
the Authentik API and pre-populates our local User doc so they appear
immediately.

services/platform-api/src/integrations/authentik.client.ts:
  - findUserByEmail(): early-conflict check before we attempt the create
  - createUser(): POST /core/users/ with username = email, internal type,
    is_active, attached to the supplied group PKs
  - addUserToGroup(): kept for tenant-member invites later
  - recoveryLink(): tries POST /core/users/{pk}/recovery/, returns
    undefined when no recovery flow is configured on the Authentik brand
    (we soft-fail and the service falls back to setInitialPassword)
  - setInitialPassword(): POST /core/users/{pk}/set_password/. Returns 204
    No Content so we bypass request<T>'s JSON parser and call fetch
    directly with explicit ok check.

services/platform-api/src/users/users.service.ts:
  - inviteOperator(dto, actor) orchestrates: dedup by email →
    findOrCreate Authentik group → create user in group → pre-create
    local User doc with platformAdmin=true so the list reflects them
    immediately → try recovery link → fall back to temp password →
    record platform.user_invited audit event with handoff method.
  - Return type is { subject, userId, link? | tempPassword? } —
    exactly one credential mode set depending on Authentik config.
  - generateTempPassword(): 16-char with at least one upper/lower/digit/
    symbol, shuffled. Confusable chars (I/O/0/1/l) omitted.
  - Cached platform-admin group ID after first lookup.

services/platform-api/src/users/users.controller.ts:
  - POST /users/invite behind OperatorGuard. Calls the service with
    actor + IP from the JWT/request.

apps/operator:
  - server/api/users/invite.post.ts: standard platformApi proxy.
  - components/InviteOperatorModal.vue: 2-step form. Step 1: name +
    email with client-side validation. Step 2: shows whichever
    credential the backend returned — recovery link OR username+
    temp-password — with copy-to-clipboard buttons and a note about
    SMTP/recovery-flow follow-up paths.
  - pages/operator-team.vue: "Invite operator" replaces "Manage in
    Authentik" as the primary action; Authentik link demoted to
    secondary. Refreshes the list on @invited so the new user shows
    up without a manual reload.

Verified end-to-end against real Authentik:
  - Invite created user pk=7, uid=f22f2bb…, group=dezky-platform-admins,
    is_active=true, temp password set. Modal showed both fields with
    copy buttons; operator-team count went 1 → 2 immediately. Audit
    event recorded (platform.user_invited with handoff='temp-password').
  - Recovery link path is preferred but Authentik has no recovery flow
    configured on the default brand. AuthentikClient.recoveryLink()
    soft-fails on the "No recovery flow set." 400, returns undefined,
    and inviteOperator transparently falls back to set_password. Once
    a recovery flow is configured (Authentik admin → Flows), the link
    path becomes active and the temp-password path stops firing
    without any code changes.

Known follow-ups:
  - Configure Authentik recovery flow so the link path activates
    (one-time admin task, not in code)
  - Outbound SMTP wiring (Phase 5/6) → Authentik can email link/temp
    directly; modal stops showing the credential
  - Deactivate / remove operator from inside the app (currently still
    Authentik UI; defensible until proven needed)
  - Tenant-member invite — similar flow but adds to tenant group
    instead, exposed from /users (global users) or tenant detail
2026-05-24 21:27:46 +02:00
Ronni Baslund b1d717e466 feat(audit): Authentik events ingest worker (Phase 2 chunk 1)
Background worker that pulls Authentik's /api/v3/events/events/ on a
60s cadence and writes each event into our audit log via AuditService.
External system events now share the same /audit timeline as
internally-recorded platform mutations — operator queries don't have
to cross-reference Authentik's own UI to see logins, password changes,
group membership, impersonation, etc.

Pieces:
- src/schemas/ingest-cursor.schema.ts: one row per source, tracks
  lastEventAt + lastEventId so restarts resume without re-pulling.
- src/schemas/audit-event.schema.ts: new `externalId` field; new
  compound unique index on (source, externalId) with a partial filter
  on externalId being a string. Partial (not sparse) so internally-
  recorded events with externalId=null don't collide.
- src/audit/audit.service.ts: AuditRecordInput grows `externalId` +
  `at` fields. record() now silently swallows MongoError code 11000
  (duplicate key) so re-pulling the cursor overlap doesn't log noise.
- src/integrations/authentik.client.ts: listEvents(since, page,
  pageSize) on the existing client — reuses the admin token and base
  URL the provisioning code already configured.
- src/ingest/action-map.ts: 16 known Authentik actions → dotted
  authentik.* verbs (login, login_failed, password_changed,
  impersonation_started, …). Unknown actions fall through to
  authentik.<raw> rather than getting silently dropped.
- src/ingest/authentik.ingest.ts: OnApplicationBootstrap worker.
  Reads cursor → pulls events with created__gt=cursor, ordering=created
  ASC → paginates forward (10 pages × 100/page safety cap per tick) →
  writes each event with source='authentik' + externalId=pk + at=
  evt.created → advances cursor to the newest seen. inFlight guard
  prevents overlapping ticks. AUDIT_INGEST_ENABLED=false disables for
  test environments.
- Tenant inference: from the user's groups (same convention the portal
  flag-eval proxy uses). Admin groups stripped; first match against a
  real Tenant.slug wins. Unmatched → tenantSlug undefined, event still
  lands in the global timeline.

Smoke-tested: fresh Mongo + restart → 78 Authentik events ingested,
0 duplicates. Performed a login at app.dezky.local → next 60s tick
captured the new login row with actor email + IP. Compound unique
index on (source, externalId) verified to reject re-pulled events
silently (no error logs).

Out of scope here (covered by chunks 2 + 3):
- Stalwart webhook ingest
- OCIS file-tail ingest
2026-05-24 20:12:21 +02:00
Ronni Baslund 22b2583f0b chore(services): rename services/provisioning -> services/platform-api
O.0 prep from OPERATOR-PLAN.md. Mechanical refactor before adding partner
management and operator-specific endpoints. The service now owns more than
just provisioning orchestration (it'll soon own partners, tenant lifecycle
actions, multi-audience JWT validation), so the name 'platform-api' reflects
its scope better.

What changed:
- Directory: services/provisioning/ -> services/platform-api/
- Package: @dezky/provisioning -> @dezky/platform-api
- Docker: container_name dezky-provisioning -> dezky-platform-api;
  compose service key 'provisioning' -> 'platform-api'; volume
  provisioning_node_modules -> platform_api_node_modules
- Portal: PROVISIONING_INTERNAL_URL env var -> PLATFORM_API_INTERNAL_URL,
  default URL http://provisioning:3001 -> http://platform-api:3001 in all
  three proxy routes (me.get.ts, tenants/index.post.ts, tenants/[slug]/
  reconcile.post.ts), plus NUXT_API_BASE updated
- Health endpoint service identifier and main.ts log lines updated to
  'dezky-platform-api'
- Docs swept: README, CLAUDE.md, SERVICES.md, AUTHENTIK-SETUP.md,
  NEXT-STEPS.md, TROUBLESHOOTING.md, OPERATOR-PLAN.md, traefik/dynamic.yml

What deliberately stays:
- Internal module names ProvisioningService / ProvisioningModule (those
  describe an orchestration sub-concern, not the service's purpose)
- Tenant.provisioningStatus / provisioningErrors field names (state
  per integration, not service name)
- File services/platform-api/src/tenants/provisioning.service.ts
- 'Hetzner provisioning' references in production-prep docs (infrastructure
  provisioning, unrelated)

Verified end-to-end after rename: /api/me returns 200 with profile + 2
tenants + subscription, /api/tenants/dezky/reconcile returns 200 with
Authentik integration still ok.

OPERATOR-PLAN.md O.0 checkboxes ticked.
2026-05-24 00:35:01 +02:00