164 Commits
v1.0.0 ... main

Author SHA1 Message Date
hsiegeln
06134d6e67 fix: TOTP label includes org name, passkeys show device as default name
Some checks failed
CI / build (push) Successful in 2m10s
CI / docker (push) Successful in 1m27s
SonarQube Analysis / sonarqube (push) Failing after 2m57s
- TOTP otpauth URI issuer changed from "Cameleer" to "Cameleer - <org>"
  so authenticator apps display the organization name
- Passkeys without a custom name now show parsed device info (e.g.
  "Chrome on Windows") instead of "Unnamed passkey"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:53:05 +02:00
hsiegeln
7fe9c581b0 fix: remove MFA card from tenant settings, constrain card widths
All checks were successful
CI / build (push) Successful in 2m10s
CI / docker (push) Successful in 1m25s
MFA enrollment now happens during sign-in. Tenant settings page reduced
to: Tenant Details + Auth Policy side-by-side (max 520px each), Passkeys
full-width below.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:45:31 +02:00
hsiegeln
7fc8a4d407 fix: team invite role resolution, user cleanup, and settings page redesign
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m33s
- Resolve org role names to Logto role IDs in invite and role change flows
  (fixes entity.relation_foreign_key_not_found on invite)
- Handle existing Logto users on re-invite instead of failing with
  email_already_in_use
- Delete users from Logto when removed from last org membership
- Consolidate tenant settings page into 3 cards: Tenant Details, MFA,
  Authentication Policy — remove duplicate MFA Enforcement and Change
  Password (now in Account Settings)
- Make passkey list scrollable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:36:21 +02:00
hsiegeln
e21a9d6046 fix: override WebAuthn type in authentication verify too
All checks were successful
CI / build (push) Successful in 1m57s
CI / docker (push) Successful in 1m30s
Same fix as registration verify — @simplewebauthn/browser returns
type: "public-key" but Logto expects type: "WebAuthn".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 20:57:37 +02:00
hsiegeln
0481cefaf4 fix: sign-in MFA flow overhaul — passkey verify, backup codes, defaults
All checks were successful
CI / build (push) Successful in 2m19s
CI / docker (push) Successful in 1m4s
Four fixes for the MFA sign-in flow:

1. Fix passkey verify crash: extract authenticationOptions from Logto
   response (was passing full response as optionsJSON). Pass
   verificationId to the verify endpoint.

2. Default to passkey verification when no MFA method preference is
   stored (was showing method picker which offered TOTP to passkey-only
   users).

3. Show backup codes after MFA enrollment: new mfaEnrollBackupCodes
   mode with copy/download buttons and confirmation checkbox. Users
   must save codes before completing sign-in.

4. Remove duplicate error alerts in enrollment screens (top-level
   alert handles all modes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 20:49:32 +02:00
hsiegeln
040ae60be5 fix: correct Experience API endpoints for TOTP and backup codes
All checks were successful
CI / build (push) Successful in 2m4s
CI / docker (push) Successful in 1m1s
- TOTP secret: /verification/totp/secret (not /verification/totp)
- Backup codes: generate via /verification/backup-code/generate first,
  then bind with the returned verificationId. Cannot bind BackupCode
  without generating codes first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:30:54 +02:00
hsiegeln
d8f7452ab7 feat: full MFA enrollment during sign-in — passkey + TOTP + backup codes
All checks were successful
CI / build (push) Successful in 2m2s
CI / docker (push) Successful in 1m10s
- Bind BackupCode after primary MFA factor (WebAuthn or TOTP) to satisfy
  Logto's requirement that backup codes accompany any MFA method.
- Add TOTP enrollment option alongside passkey on the enrollment screen:
  "Use passkey" / "Use authenticator app" / "Set up later".
- TOTP enrollment shows QR code + secret + 6-digit verification inline
  in the sign-in UI, using Experience API endpoints.
- Added createTotpSecret() and verifyTotpSetup() to experience-api.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:22:53 +02:00
hsiegeln
c4fe16048c fix: include WebAuthn in bootstrap MFA factors
All checks were successful
CI / build (push) Successful in 2m14s
CI / docker (push) Successful in 25s
Bootstrap only set [Totp, BackupCode] — WebAuthn was missing. Now
matches LogtoStartupConfig: all three factors available from first boot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:04:47 +02:00
hsiegeln
cba420fbeb fix: always offer MFA+passkey enrollment, separate availability from enforcement
All checks were successful
CI / build (push) Successful in 2m19s
CI / docker (push) Successful in 1m43s
Two fundamental fixes:

- user.missing_mfa now triggers MfaEnrollmentError (enroll UI) instead
  of MfaRequiredError (verify UI). Users without MFA were shown a TOTP
  code prompt they couldn't fill.
- Logto MFA factors always set to [Totp, WebAuthn, BackupCode] with
  UserControlled policy on startup. Availability is always-on for all
  users. The vendor auth policy controls enforcement (via
  MfaEnforcementFilter), not what Logto offers during sign-in.
- Removed syncMfaConfigToLogto from VendorAuthPolicyController — vendor
  policy changes no longer modify Logto's sign-in experience.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:59:21 +02:00
hsiegeln
67ec409383 fix: null display name, settings scrollbar, redundant passkey offer
All checks were successful
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 1m36s
- Profile API returns empty string instead of "null" when Logto user
  has no display name set (String.valueOf(null) → "null" bug).
- SettingsPage: add overflowY auto + flex 1 so content scrolls within
  the AppShell layout (which uses overflow: hidden).
- Remove redundant passkey offer from onboarding page — passkey
  enrollment now happens during sign-in via the Experience API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:53:13 +02:00
hsiegeln
3384510f3c fix: passkeys work independently of MFA mode
All checks were successful
CI / build (push) Successful in 2m15s
CI / docker (push) Successful in 1m1s
When MFA mode is off but passkeys are enabled, WebAuthn + BackupCode
factors are still synced to Logto. Previously, MFA off cleared all
factors including WebAuthn, so passkey enrollment was never offered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:45:30 +02:00
hsiegeln
18e6f32f90 refactor: move passkey enrollment to sign-in UI via Experience API
All checks were successful
CI / build (push) Successful in 2m12s
CI / docker (push) Successful in 1m49s
Remove the SaaS backend proxy approach for passkey registration (Account
API binding, Management API proxy, password modal in PasskeySection).
Instead, offer passkey enrollment natively during sign-in via Logto's
Experience API — the correct architectural layer.

Sign-in flow: when Logto returns MFA enrollment available (422), show a
"Secure your account" screen with Register passkey / Set up later. Uses
Experience API WebAuthn registration endpoints. Works for all users
(SaaS and future server users) since the sign-in UI is shared.

PasskeySection in account settings now only manages existing passkeys
(list/rename/delete) and directs users to register during sign-in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:33:46 +02:00
hsiegeln
4df6fc9e03 fix: proxy passkey bind through Management API
All checks were successful
CI / build (push) Successful in 2m17s
CI / docker (push) Successful in 1m29s
Logto's /api/my-account/ endpoints reject the opaque access token with
401 even though /api/verifications/ accepts it. The bind step now goes
through the SaaS backend which calls the Management API instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:23:41 +02:00
hsiegeln
2aa5100530 fix: add password re-verification before passkey registration
All checks were successful
CI / build (push) Successful in 2m28s
CI / docker (push) Successful in 1m32s
Logto Account API requires identity verification (logto-verification-id
header) for sensitive MFA operations. Adds a password prompt modal before
the WebAuthn ceremony — verifies password first, then proceeds with
passkey registration using the verification record ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:10:47 +02:00
hsiegeln
c360d9ad5f fix: override WebAuthn credential type for Logto Account API
All checks were successful
CI / build (push) Successful in 3m29s
CI / docker (push) Successful in 2m14s
@simplewebauthn/browser returns type: "public-key" (W3C standard) but
Logto's verify endpoint expects type: "WebAuthn".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:59:53 +02:00
hsiegeln
e7952dd9de fix: keep vendor sidebar active on account settings page
All checks were successful
CI / build (push) Successful in 1m54s
CI / docker (push) Successful in 1m26s
Vendor sidebar collapsed and tenant sidebar appeared when navigating to
/settings/account because onVendorRoute was false for non-/vendor paths.
Now vendor users stay on vendor sidebar for all routes except /tenant/*.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:18:09 +02:00
hsiegeln
687598952f fix: correct Account Center enablement — mfa field is a string enum
All checks were successful
CI / build (push) Successful in 2m6s
CI / docker (push) Successful in 1m6s
Logto's PATCH /api/account-center expects mfa as 'Off'|'ReadOnly'|'Edit',
not a nested object. Fixes 400 Bad Request on startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:14:31 +02:00
hsiegeln
c22580e124 feat: always enable WebAuthn in MFA factors and add passkey registration
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m26s
- Sync vendor auth policy to Logto sign-in experience on save and on
  startup. Always include WebAuthn + TOTP + BackupCode in MFA factors
  when MFA is enabled — no reason to gate passkeys behind a toggle.
- Enable Logto Account Center on startup for user-facing MFA management.
- Add passkey registration to account settings via Logto Account API.
  Frontend calls Logto directly (same domain) for the WebAuthn ceremony:
  generate options, browser credential creation, verify, and bind.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:01:58 +02:00
hsiegeln
a5c20830a7 fix: prevent MFA lockout and move enrollment to modal dialog
All checks were successful
CI / build (push) Successful in 1m58s
CI / docker (push) Successful in 1m47s
Three fixes for MFA enrollment and sign-in:

- Defer TOTP registration with Logto until after 6-digit code verification.
  Previously setupTotp() immediately registered the secret, so abandoning
  enrollment mid-way left MFA active without a working authenticator.
- Move entire MFA enrollment flow (QR code, verify, backup codes) into a
  Modal dialog instead of replacing the Card content inline.
- Fix sign-in MFA flow: submitMfa() no longer calls identifyUser() after
  TOTP verify — user is already identified, and passing the MFA
  verificationId to identification returned 422 ("method not activated").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:25:15 +02:00
hsiegeln
9231a1fc60 fix: move forgot password link below sign-in button
All checks were successful
CI / build (push) Successful in 2m0s
CI / docker (push) Successful in 1m7s
Repositions the "Forgot password?" link from above the sign-in button
to below it, matching the desired layout. Updates link style to be
centered with link color instead of right-aligned muted text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:06:36 +02:00
f325416833 Merge pull request 'feature/vendor-admin-account-settings' (#60) from feature/vendor-admin-account-settings into main
All checks were successful
CI / build (push) Successful in 3m22s
CI / docker (push) Successful in 27s
Reviewed-on: #60
2026-04-27 15:58:05 +02:00
hsiegeln
ab800bbef9 fix: handle Logto data URI in MFA QR code display
All checks were successful
CI / build (push) Successful in 2m7s
CI / docker (push) Successful in 1m31s
CI / build (pull_request) Successful in 3m23s
CI / docker (pull_request) Has been skipped
Logto's secretQrCode is a data:image/png;base64 URI, not an otpauth://
string. QRCodeSVG crashes trying to encode it ("Data too long"). Now
renders data URIs as <img> and only uses QRCodeSVG for otpauth:// URIs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 15:43:39 +02:00
hsiegeln
15d6c7abc1 fix: remove explicit pagination from Logto role API calls
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 59s
Logto's /api/roles/{id}/users endpoint rejects page=1 with
guard.invalid_pagination. Remove explicit pagination params and
let Logto use its defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 15:40:05 +02:00
0b4d0e3b2f Merge pull request 'feat: vendor admin management and shared account settings' (#59) from feature/vendor-admin-account-settings into main
All checks were successful
CI / build (push) Successful in 2m15s
CI / docker (push) Successful in 20s
Reviewed-on: #59
2026-04-27 15:20:23 +02:00
hsiegeln
f823a409d0 fix: add AccountService mock to TenantPortalServiceTest constructor
All checks were successful
CI / build (push) Successful in 3m9s
CI / build (pull_request) Successful in 3m8s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 1m43s
The TenantPortalService constructor gained an AccountService parameter
in the consolidation refactor — the test was missing it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 15:15:19 +02:00
hsiegeln
e9e18f6c38 docs: update CLAUDE.md for account package, vendor admins, and shared components
Some checks failed
CI / build (push) Failing after 2m1s
CI / docker (push) Has been skipped
CI / build (pull_request) Failing after 1m46s
CI / docker (pull_request) Has been skipped
- Add account/ package to Key Packages table
- Add VendorAdminService/Controller to vendor/ package
- Note TenantPortalService delegation to AccountService
- Update ui/CLAUDE.md: AccountSettingsPage, VendorAdminsPage,
  Administrators sidebar, user menu dropdown, shared components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 15:09:41 +02:00
hsiegeln
372d3c77a0 fix: code review findings — dead catch blocks, notification email, role verification
- Remove dead IllegalArgumentException catch blocks in TenantPortalController
  (delegated methods now throw ResponseStatusException, handled by Spring)
- Add password reset notification email in VendorAdminService.resetAdminPassword
- Add verifyIsVendorAdmin guard to resetAdminPassword and resetAdminMfa
  to prevent platform admins from resetting arbitrary non-admin users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 15:06:16 +02:00
hsiegeln
e5e0cad7c3 refactor: consolidate tenant SettingsPage to use shared account components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:59:09 +02:00
hsiegeln
8668642b8d feat: add account settings route and user menu dropdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:58:27 +02:00
hsiegeln
d44ee4b977 feat: add VendorAdminsPage with list, create/invite, remove, reset actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:56:03 +02:00
hsiegeln
5d1d263c74 feat: add AccountSettingsPage composing shared account components 2026-04-27 14:54:26 +02:00
hsiegeln
e563631efb feat: extract shared account components (Profile, Password, MFA, Passkey)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:53:05 +02:00
hsiegeln
bf42f13afc feat: add TypeScript types and React Query hooks for account and vendor admin APIs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:49:44 +02:00
hsiegeln
0da1ffea7f fix: guard against null orgId in createAndInviteUser and createUserWithPassword
Vendor admins use global roles, not org roles — passing null orgId
would previously cause addUserToOrganization to call
/api/organizations/null/users and fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:48:00 +02:00
hsiegeln
022b6d9722 feat: add vendor admin management (list, create/invite, remove, reset password/MFA)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:46:42 +02:00
hsiegeln
665ffefd3e refactor: use AccountService for display name in OnboardingService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:46:20 +02:00
hsiegeln
cc3d2dc111 refactor: delegate TenantPortalService MFA/password/passkey methods to AccountService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:44:20 +02:00
hsiegeln
ab240e42b0 feat: add /api/account/** security config and MFA enforcement exemptions
Permit /settings/** SPA route, gate /api/account/** as authenticated,
and exempt account MFA/profile/password paths from MFA enforcement filter.
2026-04-27 14:41:21 +02:00
hsiegeln
b63e5e9c81 feat: add AccountController with /api/account/* endpoints 2026-04-27 14:41:05 +02:00
hsiegeln
90d84ffd00 feat: add AccountService extracting user identity operations from TenantPortalService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:39:40 +02:00
hsiegeln
19428b4e27 feat: add password verify and role management methods to LogtoManagementClient
Adds verifyUserPassword (for current-password check before password change) and
four global role methods (listRoleUsers, getRoleByName, assignGlobalRole,
revokeGlobalRole) needed by the upcoming AccountService and VendorAdminService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:36:59 +02:00
hsiegeln
316e5ef6c1 docs: implementation plan for vendor admin management and account settings
16 tasks covering: LogtoManagementClient additions, AccountService
extraction, AccountController, VendorAdminService/Controller,
SecurityConfig updates, frontend component extraction, shared
AccountSettingsPage, VendorAdminsPage, and Layout user menu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:32:46 +02:00
hsiegeln
86d9ba4985 docs: vendor admin management and account settings design spec
Two features: multi-vendor admin management (invite/create, remove,
reset password/MFA) and shared account settings page (profile, password
change with current-password verification, MFA self-service). Includes
consolidation plan extracting user-level identity operations from
TenantPortalService into new AccountService.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:20:49 +02:00
hsiegeln
292adeea4c docs: update documentation for passkey MFA feature
All checks were successful
CI / build (push) Successful in 2m23s
CI / docker (push) Successful in 2m19s
- Add V002/V003 migrations and VendorAuthPolicy classes to CLAUDE.md
- Document MFA & passkey enforcement model in config CLAUDE.md
- Mark passkey MFA design spec as Implemented

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:51:12 +02:00
hsiegeln
43a1058f33 fix: code review findings — auth-settings HTTP method, authorization, redirect
- Change auth-settings endpoint from PUT to PATCH (matches partial update semantics and frontend hook)
- Add @PreAuthorize("SCOPE_tenant:manage") to updateAuthSettings endpoint
- Consolidate MFA/passkey 403 redirect handling in API client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:01:23 +02:00
hsiegeln
60a800f757 feat: add passkey offer step to onboarding wizard
After tenant creation, checks vendor auth policy and conditionally
shows a passkey enrollment offer screen before redirecting. User
can skip and set up later.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:55:24 +02:00
hsiegeln
76a62135ab feat: add WebAuthn and method picker modes to sign-in UI
Adds mfaWebauthn and mfaMethodPicker modes with smart routing based on
stored preference (localStorage). Auto-triggers passkey prompt on mode
entry. Adds "Use passkey instead" link in TOTP mode. Saves method
preference on successful verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:55:16 +02:00
hsiegeln
17ba02c30d feat: add WebAuthn Experience API functions to sign-in UI
Adds startWebAuthnAuth and verifyWebAuthnAuth functions that call
the Logto Experience API WebAuthn endpoints for passkey MFA verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:55:10 +02:00
hsiegeln
9b898924ab feat: add passkey management and auth policy sections to tenant settings
Adds PasskeySection (list/rename/delete passkeys), AuthPolicySection
(MFA mode + passkey enable/mode controls), and PasskeyNudgeBanner
(dismissable nudge for users without a passkey enrolled).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:55:04 +02:00
hsiegeln
8de16019b7 feat: add vendor authentication policy management page
Adds /vendor/auth-policy route with MFA mode (off/optional/required) and passkey (enabled/disabled, optional/preferred/required mode) controls, including a confirmation guard before enforcing required MFA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:51:45 +02:00
hsiegeln
ad2b16f26d feat: add passkey and auth policy React Query hooks
Adds hooks for listing/renaming/deleting passkeys, MFA method preference,
tenant auth settings, and vendor auth policy (using the new putJson method).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:48:44 +02:00
hsiegeln
2007a4b2da feat: add passkey types and APP_PASSKEY_REQUIRED handling
Extends MfaStatus with passkeyEnrolled/passkeyCount fields, adds
PasskeyCredential and AuthPolicy types, expands TenantSettings with
passkey fields, handles APP_PASSKEY_REQUIRED 403 redirect, and adds
putJson method to the api client for JSON PUT requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:48:39 +02:00
hsiegeln
9057479da7 feat: expose vendor auth policy in public config endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:48:13 +02:00
hsiegeln
89c83ec7b8 feat: expand MfaEnforcementFilter for vendor policy and passkey checks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:48:09 +02:00
hsiegeln
b3104dc410 feat: add passkey and auth settings endpoints to TenantPortalController 2026-04-27 08:45:52 +02:00
hsiegeln
5bf94c6d4e feat: add passkey management and auth settings to TenantPortalService 2026-04-27 08:45:49 +02:00
hsiegeln
40daca36a0 feat: add WebAuthn credential and custom data methods to LogtoManagementClient 2026-04-27 08:45:45 +02:00
hsiegeln
8c9edfdb55 feat: add passkey_enrolled and mfa_method_preference to Custom JWT claims
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:44:42 +02:00
hsiegeln
25f4afcddc feat: add vendor auth policy REST endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:42:59 +02:00
hsiegeln
02be1d9264 feat: add VendorAuthPolicy entity and repository
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:42:55 +02:00
hsiegeln
cc7c87a520 feat: add vendor_auth_policy table for passkey MFA support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:42:51 +02:00
hsiegeln
ca19faf4f0 docs: add passkey MFA implementation plan
18-task plan covering database migration, backend policy/endpoints,
sign-in UI WebAuthn modes, and platform UI management pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:39:44 +02:00
hsiegeln
b86cc812b7 docs: add passkey MFA design spec
Logto-native WebAuthn approach with independent vendor/tenant policy
domains, three registration entry points, and smart MFA method defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:26:09 +02:00
hsiegeln
f0dda0d2ee fix(ui): clean up tenant pages and add license inspection
All checks were successful
CI / build (push) Successful in 2m6s
CI / docker (push) Successful in 1m28s
- Remove tier badge from tenant license page header
- Remove tier badge and Tier KPI card from tenant dashboard
- Add "Inspect License" toggle on vendor tenant detail to view all limits

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:51:29 +02:00
hsiegeln
3cd6bd5585 chore: update GitNexus index stats in documentation
Some checks failed
CI / build (push) Successful in 2m16s
CI / docker (push) Successful in 1m38s
SonarQube Analysis / sonarqube (push) Failing after 2m36s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 22:14:42 +02:00
hsiegeln
25d66af45e fix(ui): hide redundant SSO button in empty state, fix dashboard navigation
Some checks failed
CI / docker (push) Has been cancelled
CI / build (push) Has been cancelled
- Hide top-right "Add SSO Connection" when no connectors exist (empty
  state already has its own button)
- Fix broken relative navigations on tenant dashboard: ../license and
  ../oidc resolved to wrong paths; now use absolute /tenant/license and
  /tenant/sso

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 22:13:41 +02:00
hsiegeln
d783040030 feat(ui): add license usage visualization with progress bars
Split license limits into metered "Resource Usage" (with color-coded
progress bars) and static "Plan Limits" cards. Updated UsageIndicator
with 8px bars, green/amber/red thresholds, and tabular-nums formatting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 22:12:33 +02:00
hsiegeln
6afc337b16 feat: add usage data to license and vendor detail endpoints
Add getAppCount() to ServerApiClient, include usage counts (agents,
environments, apps, users) in tenant license and vendor detail responses
so the frontend can render progress bars against license limits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 22:08:47 +02:00
hsiegeln
e881e302b6 fix(ui): check ApiError.status instead of message string for 404 detection
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m27s
The ApiError class (088bc34) extracts messages from response bodies, so
a 404 with no body produces "Request failed" — not "404". The email
connector hook's string check failed, treating "not configured" as an
error and showing "Failed to load config" on fresh installs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 21:28:53 +02:00
hsiegeln
d7ef2c488b fix: use valid STARTER tier in onboarding tenant creation
All checks were successful
CI / build (push) Successful in 2m17s
CI / docker (push) Successful in 1m16s
OnboardingService passed "LOW" as the tier, but the Tier enum only has
STARTER/TEAM/BUSINESS/ENTERPRISE. Tier.valueOf("LOW") threw
IllegalArgumentException, which the controller caught as a blanket 409
Conflict — masking the real cause. Also catch IllegalStateException
(user already has a tenant) to return 409 instead of 500.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 21:17:23 +02:00
hsiegeln
088bc34e67 fix(ui): extract meaningful error messages from API responses
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m28s
Introduces ApiError class in client.ts that parses Spring Boot error
bodies to extract human-readable messages (message, error, detail fields).
Adds errorMessage() helper used by all toast descriptions instead of
raw String(err) which dumped JSON blobs to the user.

Affected: all 10 page components that display error toasts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 21:10:28 +02:00
hsiegeln
73e41e5607 fix(ci): force SNAPSHOT updates in build job Maven command
All checks were successful
CI / build (push) Successful in 2m15s
CI / docker (push) Successful in 1m31s
The actions/cache restored a stale ~/.m2/repository with the old
cameleer-license-minter SNAPSHOT (pre-license-api extraction).
Adding -U forces re-resolution of SNAPSHOT dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 20:58:06 +02:00
hsiegeln
f5b68c212b fix: force SNAPSHOT updates in Docker build to resolve stale cache
Some checks failed
CI / build (push) Failing after 1m2s
CI / docker (push) Has been skipped
The BuildKit cache mount for ~/.m2/repository persists the old
cameleer-license-minter SNAPSHOT (which depended on server-core).
Adding -U forces Maven to re-resolve SNAPSHOTs from the Gitea
registry, picking up the updated minter that depends on license-api.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 20:55:41 +02:00
hsiegeln
7c82ba93b0 refactor: update imports for cameleer-license-api package extraction
Some checks failed
CI / build (push) Successful in 3m6s
CI / docker (push) Failing after 30s
Server team extracted license types into cameleer-license-api (#156).
Package moved from com.cameleer.server.core.license to com.cameleer.license.
Dependency tree is now: cameleer-saas → minter → license-api (no server-core).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 20:50:18 +02:00
hsiegeln
1066101e8a fix: add Gitea Maven repository for cameleer-license-minter resolution
All checks were successful
CI / build (push) Successful in 2m31s
CI / docker (push) Successful in 1m25s
CI needs to resolve com.cameleer:cameleer-license-minter from the
Gitea package registry. Without this repository declaration, the
dependency only resolved from the local Maven cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 19:31:27 +02:00
hsiegeln
ffb7ef0839 feat(ui): add license minting form, verify tool, and update all pages
Some checks failed
CI / build (push) Successful in 2m42s
CI / docker (push) Failing after 51s
Vendor UI:
- TenantDetailPage: full minting form with tier presets, 13 configurable
  limits, expiry/grace period, label. Mint & Push or Mint & Copy actions.
  License bundle display with all three env vars for standalone deployment.
- LicenseVerifyPage: paste token to decode + validate signature, shows
  envelope details and state badge. Public key viewer with copy button.
- Layout: added "License Tools" nav item under Vendor section.
- vendor-hooks: useMintLicense, useLicensePresets, useVerifyLicense, usePublicKey

Tenant UI:
- TenantLicensePage: replaced features card with full 13-key limits display,
  added grace period and label fields
- TenantDashboardPage: fixed limit keys (agents→max_agents, environments→max_environments)

Common:
- Updated types (dropped features, added label/gracePeriodDays/bundle types)
- Updated tier colors for STARTER/TEAM/BUSINESS/ENTERPRISE
- Updated CreateTenantPage tier dropdown

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:41:40 +02:00
hsiegeln
4dea1c6764 feat: push Ed25519 public key to tenant server containers
DockerTenantProvisioner now injects CAMELEER_SERVER_LICENSE_PUBLICKEY
env var on provisioned server containers, enabling cryptographic
license validation. SigningKeyService passed through auto-config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:36:06 +02:00
hsiegeln
6c3f21d4db test: update all tests for Ed25519 license minting and tier rename
- LicenseServiceTest: mock SigningKeyService, assert signed token format
- VendorTenantServiceTest: add SigningKeyService mock, update mintLicense stubs
- All tests: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
- Remove all features-related test assertions
- 80/80 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:33:10 +02:00
hsiegeln
7a8960ca46 feat: add vendor license minting, presets, and verify endpoints
- POST /vendor/tenants/{id}/license now accepts MintLicenseRequest body
  with custom limits, expiresAt, gracePeriodDays, label, pushToServer
- Returns LicenseBundleResponse with token + public key + tenant slug
- GET /vendor/license-presets returns tier preset limits
- POST /vendor/license/verify decodes and validates signed tokens
- GET /vendor/signing-key/public returns the Ed25519 public key
- VendorTenantService.mintLicense() supports configurable minting
- Updated portal DTOs to drop features, add label + gracePeriodDays

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:24:23 +02:00
hsiegeln
fdc7187424 feat: rewrite LicenseService to mint Ed25519-signed tokens
Replaces UUID token generation with LicenseMinter.mint() from
cameleer-license-minter. Adds full-control generateLicense() overload
accepting custom limits, expiry, grace period, and label.
Adds verifyToken() and verifyTokenSignature() using LicenseValidator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:22:05 +02:00
hsiegeln
2fd14165bc feat: add SigningKeyService for Ed25519 keypair management
Entity, repository, and service for generating and storing Ed25519
signing keys. Lazy-initializes on first call — generates keypair
and persists to signing_keys table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:21:16 +02:00
hsiegeln
13bd03997a refactor: rename tiers and rewrite LicenseDefaults to 13-key cap matrix
- Tier enum: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
- LicenseDefaults: 13-key limits per tier matching server handoff cap matrix
- Drop features concept from LicenseEntity, LicenseResponse, portal DTOs
- Add label and gracePeriodDays to LicenseEntity
- Fix agent limit key from 'agents' to 'max_agents' in VendorTenantController

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:20:22 +02:00
hsiegeln
e64bf4f0d1 feat: add cameleer-license-minter dependency and V002 migration
Adds Ed25519 license minting library, signing_keys table,
renames tiers (LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE),
adds label + grace_period_days to licenses, drops features column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:17:44 +02:00
hsiegeln
883e10ba7c feat: test SMTP connection on save and retain password on edit
All checks were successful
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 1m36s
Adds testSmtpConnection() that performs EHLO + auth via JavaMailSender
before persisting to Logto — saves fail fast with a clear error if
SMTP credentials are wrong. Password is now optional when editing:
if left blank the backend fetches the existing password from Logto's
connector config, so users can update host/port/fromEmail without
re-entering the password every time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 16:23:42 +02:00
hsiegeln
0413a5b882 fix: remove HTML document wrapper from email templates for GMX compat
All checks were successful
CI / build (push) Successful in 2m12s
CI / docker (push) Successful in 1m5s
GMX webmail broke after adding <!DOCTYPE html><html><head><body>
wrappers — the Logto SMTP connector sets these as nodemailer's html
field, and GMX's sanitizer chokes on a full document inside its own
page shell. Reverts to bare HTML fragments (the format that worked
before 12:17 commit 484a388) while keeping the extra text paragraphs
added for mail checker text-to-HTML ratio compliance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 16:08:04 +02:00
hsiegeln
c6b6bafc0f fix: revert email templates to inline styles for GMX webmail compat
All checks were successful
CI / build (push) Successful in 1m59s
CI / docker (push) Successful in 1m52s
GMX webmail strips <head> content including <style> blocks, rendering
emails as unstyled plain text. Reverts to inline styles (the only
reliable approach for email HTML) while keeping the proper HTML document
structure and extra text content added for mail checker compliance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:56:10 +02:00
hsiegeln
988035b952 fix: handle MFA binding skip during registration submit
The registration flow hit a 422 on /api/experience/submit when MFA
policy is UserControlled. Adds the same trySubmit + skipMfaBinding
pattern already used in the sign-in flow — Logto confirms mfa-skipped
works for both SignIn and Register interaction events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:52:46 +02:00
hsiegeln
c55427c22b fix: restore watermark image in email templates
All checks were successful
CI / build (push) Successful in 2m18s
CI / docker (push) Successful in 1m10s
The previous commit incorrectly removed the watermark — only the
style extraction into <style> blocks was requested. Restores the
watermark <img>, {{watermarkUrl}} placeholder resolution in both
EmailConnectorService and PasswordResetNotificationService, and
the corresponding test assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:14:35 +02:00
hsiegeln
f681784e7e fix: move email styles to <style> block and remove watermark image
Some checks failed
CI / build (push) Successful in 1m59s
CI / docker (push) Has been cancelled
Extracts repeated inline styles into <head> <style> to improve the
text-to-HTML ratio flagged by mail checkers. Removes the decorative
watermark <img> (opacity 0.07, barely visible) which was the only
image element and triggered the "too many images" classification.
Cleans up the now-unused ProvisioningProperties dependency from
EmailConnectorService and PasswordResetNotificationService.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:11:53 +02:00
hsiegeln
7b57ee8246 fix: add proper HTML document structure and more text to email templates
All checks were successful
CI / build (push) Successful in 2m35s
CI / docker (push) Successful in 1m6s
Mail checkers flagged missing <html> tag and insufficient text content.
Wraps all 5 templates in DOCTYPE/html/head/body, adds Outlook conditional
comments, and includes a descriptive paragraph to improve text-to-image ratio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:57:47 +02:00
hsiegeln
6e6e4218c9 fix: skip MFA binding prompt for UserControlled policy during sign-in
All checks were successful
CI / build (push) Successful in 2m1s
CI / docker (push) Successful in 59s
Logto returns 422 with an MFA recommendation when policy is
UserControlled. Call POST /profile/mfa/mfa-skipped to skip the
binding prompt, then re-submit. Users who already have MFA enrolled
still get the TOTP verification flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:43:55 +02:00
hsiegeln
469b36613b fix: resolve CI type errors in TeamPage and install qrcode.react
All checks were successful
CI / build (push) Successful in 2m52s
CI / docker (push) Successful in 2m16s
- Change Button size="small" to size="sm" (design system API)
- Remove unsupported style prop from Card component
- Ensure qrcode.react is properly installed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:29:59 +02:00
hsiegeln
bcb8a040f4 docs: add MFA handoff document for cameleer-server team
Some checks failed
CI / build (push) Failing after 38s
CI / docker (push) Has been skipped
Covers JWT mfa_enrolled claim, enforcement model (APP_MFA_REQUIRED),
Logto Management API contract for TOTP enrollment and backup codes,
UX requirements, and error states.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:07:27 +02:00
hsiegeln
d52084a081 feat: add Reset MFA action for team members
Adds a Reset MFA button in the Actions column and an inline confirmation
card (with warning Alert) that calls useResetTeamMemberMfa on confirm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 14:06:20 +02:00
hsiegeln
7e7407b137 feat: add MFA enrollment and enforcement toggle to Settings page
Adds two new sections to the tenant Settings page:
- MfaSection: TOTP authenticator setup with QR code, 6-digit verification,
  backup code display (2-column grid with copy/download), and MFA removal
- MfaEnforcementToggle: tenant admin control to require MFA for all members,
  with confirmation dialog before enabling

Installs qrcode.react for QR code rendering. Uses existing MFA hooks from
tenant-hooks.ts and design-system components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:04:28 +02:00
hsiegeln
0a77080bca feat: add MFA types, hooks, and APP_MFA_REQUIRED interceptor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 14:01:04 +02:00
hsiegeln
a5b30cd1ea feat: add password reset security notification email endpoint
Adds POST /api/password-reset-notification (public, rate-limited 3/10min)
that sends a branded HTML security notification email via the runtime-
configured Logto SMTP connector. Uses spring-boot-starter-mail with a
programmatic JavaMailSender built from the connector's live credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:59:23 +02:00
hsiegeln
ffb65edcec feat: add MFA enforcement filter with APP_MFA_REQUIRED error code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:56:25 +02:00
hsiegeln
8b8909e488 feat: add MFA enrollment, removal, and settings endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:53:44 +02:00
hsiegeln
94de4c2a5b feat: add MFA Management API methods to LogtoManagementClient
Add 5 new methods for MFA operations via Logto Management API:
- getUserMfaVerifications: list all MFA factors for a user
- createTotpVerification: create TOTP MFA verification
- createBackupCodes: generate backup codes
- deleteMfaVerification: delete a specific MFA verification
- deleteAllMfaVerifications: delete all MFA verifications (admin reset)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:48:29 +02:00
hsiegeln
66477ff575 feat: configure MFA factors + mfa_enrolled JWT claim in Logto bootstrap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:46:10 +02:00
hsiegeln
6c70efcb54 feat: add MFA verification (TOTP + backup code) to sign-in flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:44:28 +02:00
hsiegeln
1f3a9551c5 feat: add forgot-password UI flow to custom sign-in page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:42:51 +02:00
hsiegeln
08a3ad03b7 feat: add forgot-password and MFA verification Experience API functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:39:31 +02:00
hsiegeln
cfcf852e2d docs: add password reset and MFA implementation plan
12-task plan covering:
- Password reset Experience API + sign-in UI
- MFA verification at sign-in (TOTP + backup codes)
- Logto bootstrap MFA config + mfa_enrolled JWT claim
- LogtoManagementClient MFA methods
- MFA enrollment endpoints + Settings page UI
- MFA enforcement filter (APP_MFA_REQUIRED)
- Password reset security notification email
- Team page Reset MFA action
- Server handoff document

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:35:42 +02:00
hsiegeln
67f7d634c9 docs: refine password reset + MFA spec from review feedback
- Add security notification email after password reset (warns MFA
  was not required, recommends enabling it)
- Use distinct APP_MFA_REQUIRED error code + X-Cameleer-Error header
  for MFA enforcement 403s to avoid collision with generic access denied
- Make backup code fallback prominent in MFA verification UI (visible
  secondary action, not a subtle link)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:26:55 +02:00
hsiegeln
6f984c6b78 docs: add password reset and MFA design spec
Covers self-service password reset via Logto Experience API,
TOTP + backup code MFA with per-tenant enforcement via JWT claims,
and a server handoff document for cameleer-server MFA enrollment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:20:48 +02:00
hsiegeln
5754b0ca81 fix: set Logto display name from email during onboarding
All checks were successful
CI / build (push) Successful in 2m12s
CI / docker (push) Successful in 1m3s
Email-registered users have no name field in Logto, causing empty OIDC
name claims. After adding user to org, derive display name from email
local part (john.doe@acme.com -> john.doe) if name is not already set.

Also adds updateUserProfile() to LogtoManagementClient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 12:31:12 +02:00
hsiegeln
484a388b62 fix: prevent grey bar when webmail blocks watermark image
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m10s
Remove width/height HTML attributes and add border:0;outline:none to
the watermark img tag so broken-image placeholders collapse gracefully
when email clients block remote images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 12:17:51 +02:00
hsiegeln
d720c0500f fix: force fresh OIDC sign-in after onboarding to pick up new org membership
All checks were successful
CI / build (push) Successful in 1m55s
CI / docker (push) Successful in 1m22s
After creating a tenant, the existing Logto tokens don't include the new
org membership/scopes. A hard page reload reused stale tokens, causing
the SDK to either lose auth state (redirect loop to login) or fail to
resolve org scopes (falling through to server UI instead of tenant UI).

Replace window.location.href with signIn() to trigger a fresh OIDC flow.
The existing Logto session cookie means auto-approval — no login form.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 12:06:39 +02:00
hsiegeln
cfa9d41b36 docs: add email template polish spec, plan, and update GitNexus index
All checks were successful
CI / build (push) Successful in 1m54s
CI / docker (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 10:37:41 +02:00
hsiegeln
b974f233f4 feat: load email templates from classpath with watermark URL resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:35:16 +02:00
hsiegeln
3741ac2658 feat: add branded HTML email templates with desert/caravan copy 2026-04-26 10:31:50 +02:00
hsiegeln
e8a726af80 feat: permit /assets/** for unauthenticated access (email watermark) 2026-04-26 10:30:33 +02:00
hsiegeln
53f0e55e93 feat: add pre-faded logo watermark for email templates 2026-04-26 10:24:02 +02:00
hsiegeln
06d114b46b feat: validate slug uniqueness during onboarding
All checks were successful
CI / build (push) Successful in 1m50s
CI / docker (push) Successful in 1m22s
Add GET /api/onboarding/slug-available endpoint to check if a slug is
already taken. Frontend checks availability with 400ms debounce as the
user types and shows inline feedback. Submit button disabled when slug
is taken. POST /api/onboarding/tenant now returns 409 instead of 500
for duplicate slugs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 09:40:17 +02:00
hsiegeln
171ed1a6ab fix: provisioning race condition and noisy ClickHouse logs
Some checks failed
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m30s
SonarQube Analysis / sonarqube (push) Failing after 2m22s
Defer provisionAsync() until after the transaction commits using
TransactionSynchronization.afterCommit(). Previously the @Async thread
raced the @Transactional commit — findById returned null because the
tenant INSERT wasn't visible yet.

Downgrade ClickHouse UNKNOWN_TABLE errors to DEBUG level in
InfrastructureService. These are expected on fresh installs before any
cameleer-server has created the tables.

Make the onboarding slug field read-only (derived from org name).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 22:05:48 +02:00
hsiegeln
dee1f39554 fix: align button icons and polish vendor sidebar
All checks were successful
CI / build (push) Successful in 2m8s
CI / docker (push) Successful in 1m41s
Fix vertical alignment of Lucide icons inside Button children across
all pages by adding verticalAlign offsets (-3px for 16px icons, -2px
for 14px icons). The design system Button wraps children in an inline
span, so SVG icons defaulted to baseline alignment.

Hide the redundant top-right "Create Tenant" button on VendorTenantsPage
when no tenants exist — the EmptyState already provides that action.

Add icons to all vendor sidebar sub-items for consistency (previously
only Email Connector had one).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:30:37 +02:00
hsiegeln
adb4ef1af8 fix: enable email sign-in method alongside username in all modes
All checks were successful
CI / build (push) Successful in 1m50s
CI / docker (push) Successful in 59s
The sign-in experience must always include both email+password and
username+password methods. The admin user signs in with their email
(admin@company.com) which the sign-in UI detects as email type.
With only username method enabled, Logto rejects it with "this
sign-in method is not activated."

Fixes both bootstrap Phase 8c and EmailConnectorService disable path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:11:07 +02:00
hsiegeln
4cc3e096b5 fix: bootstrap extracts username from admin email for Logto
All checks were successful
CI / build (push) Successful in 1m47s
CI / docker (push) Successful in 20s
Logto rejects @ in usernames. Extract local part (before @) as the
Logto username, use full email as primaryEmail. Also validates admin
user creation succeeded (logs error instead of silently continuing
with null ID).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:03:54 +02:00
hsiegeln
1d26ae481e docs: update user manual for current UI and identity model
All checks were successful
CI / build (push) Successful in 1m58s
CI / docker (push) Successful in 21s
- Sign-in instructions: "Enter your email" (not "email or username")
- Troubleshooting: remove reference to deleted "Sign in with Logto" button
- Sidebar navigation: replace outdated single table with vendor console
  and tenant portal sections reflecting current sidebar structure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:53:19 +02:00
hsiegeln
8fe18c7f83 feat: unify admin identity — SAAS_ADMIN_USER is the email in SaaS mode
All checks were successful
CI / build (push) Successful in 1m56s
CI / docker (push) Successful in 1m32s
In SaaS mode, SAAS_ADMIN_USER must be an email address. It's used as
both the Logto username and primaryEmail. No separate SAAS_ADMIN_EMAIL.
Installer enforces email format in SaaS mode (moved deployment mode
question before admin credentials), accepts any username in standalone.
Sign-in form label changed to "Login".

Removes SAAS_ADMIN_EMAIL from bootstrap, compose template, installers,
and all documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:46:24 +02:00
hsiegeln
929e7d5aed chore: update installer submodule (add SAAS_ADMIN_EMAIL to both installers)
All checks were successful
CI / build (push) Successful in 1m57s
CI / docker (push) Successful in 20s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:26:57 +02:00
hsiegeln
3ab6408258 feat: enforce email as primary user identity in SaaS mode
All checks were successful
CI / build (push) Successful in 2m23s
CI / docker (push) Successful in 53s
All users in SaaS mode must have an email address. The bootstrap creates
the admin user with primaryEmail set to SAAS_ADMIN_EMAIL (defaults to
<SAAS_ADMIN_USER>@<PUBLIC_HOST>). This prevents the admin from being
locked out when self-service registration (which requires email) is
enabled via the Email Connector UI.

Documentation updated across all CLAUDE.md files, .env.example,
user-manual.md, and installer submodule (README, .env.example, compose).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:23:30 +02:00
hsiegeln
f0aa2b7d3a fix: reset signUp identifiers when disabling registration
All checks were successful
CI / build (push) Successful in 1m45s
CI / docker (push) Successful in 1m17s
When registration is disabled, signUp.identifiers must be reset to
["username"] with verify:false. Otherwise Logto enforces email as a
mandatory profile field on all users, blocking username-only users
(like the admin) from signing in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:08:46 +02:00
hsiegeln
9bf6c17d63 fix: hide registration option when sign-in mode is SignIn only
Some checks failed
CI / build (push) Successful in 2m4s
CI / docker (push) Has been cancelled
Fetch /api/.well-known/sign-in-exp on mount and check signInMode.
If not SignInAndRegister, hide the "Sign up" link and force sign-in
mode (even if ?first_screen=register was in the URL).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:06:17 +02:00
hsiegeln
1a4ae5b49b fix: style signed-out page to match sign-in UI
Some checks failed
CI / build (push) Successful in 2m12s
CI / docker (push) Has been cancelled
Use same layout as SignInPage: bg-base background, 400px card,
Cameleer logo with text header, matching font sizes and spacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:03:04 +02:00
hsiegeln
400c32a539 fix: use sessionStorage instead of query param for logout flag
All checks were successful
CI / build (push) Successful in 2m2s
CI / docker (push) Successful in 1m12s
Logto does exact-match on post_logout_redirect_uri, so ?signed_out
caused "not registered" error. Use sessionStorage flag instead —
set before signOut, read and cleared on LoginPage mount.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 19:56:10 +02:00
hsiegeln
2cb818ec71 fix: prevent logout loop by showing signed-out state instead of auto-redirecting
All checks were successful
CI / build (push) Successful in 2m45s
CI / docker (push) Successful in 1m50s
After logout, redirect to /platform/login?signed_out which shows a
"Signed out" card with a "Sign in again" button instead of immediately
redirecting back to Logto OIDC (which would auto-authenticate if the
Logto session cookie persists).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 18:52:26 +02:00
hsiegeln
37668dcfe0 docs: update all documentation for email connector UI migration
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m34s
- CLAUDE.md: add EmailConnectorService/Controller to vendor package
- .env.example: replace SMTP vars with note about runtime UI config
- docker/CLAUDE.md: update sign-in UI and bootstrap descriptions
- ui/CLAUDE.md: add EmailConfigPage, update sidebar and registration notes
- ui/sign-in/Dockerfile: update connector install comment
- installer: update README, .env.example (submodule)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 18:16:19 +02:00
hsiegeln
40ea6e5e69 docs: update docker CLAUDE.md and installer submodule for SMTP removal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 18:09:57 +02:00
hsiegeln
6ab0a3c5a1 chore: update installer submodule (remove SMTP from both installers) 2026-04-25 18:08:51 +02:00
hsiegeln
8130f2053d chore: update installer submodule (remove SMTP from compose)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:05:42 +02:00
hsiegeln
9da908e4d2 feat: remove SMTP connector from bootstrap, default to sign-in only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:05:17 +02:00
hsiegeln
d0dba73a29 feat: add email connector route and sidebar navigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:03:39 +02:00
hsiegeln
9aa535ace8 feat: add EmailConfigPage with SMTP form, registration toggle, and test email 2026-04-25 18:02:30 +02:00
hsiegeln
f85b5a3634 feat: add React Query hooks for email connector API 2026-04-25 18:00:47 +02:00
hsiegeln
39e1b39f7a feat: add EmailConnectorController with CRUD, test, and registration toggle endpoints 2026-04-25 17:59:40 +02:00
hsiegeln
283d3e34a0 feat: add EmailConnectorService for Logto email connector management 2026-04-25 17:58:26 +02:00
hsiegeln
2cd15509ba feat: add email connector and sign-in experience methods to LogtoManagementClient
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:56:37 +02:00
hsiegeln
9d87f71bc1 docs: add email connector UI design spec and implementation plan
Move email connector configuration from installer/bootstrap into the
vendor admin UI for runtime control over SMTP delivery and self-service
registration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 17:50:47 +02:00
hsiegeln
6b77a96d52 fix: handle special characters in passwords during setup
All checks were successful
CI / build (push) Successful in 1m38s
CI / docker (push) Successful in 22s
- Logto entrypoint builds DB_URL from PG_USER/PG_PASSWORD/PG_HOST with
  URL-encoding via node's encodeURIComponent, instead of embedding the
  raw password in the connection string
- Installer submodule updated: passwords single-quoted in .env/.conf

Fixes SMTP and DB auth failures when passwords contain $, &, ;, [, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 16:34:00 +02:00
hsiegeln
c58bf90604 chore: update installer submodule (restore install.ps1)
All checks were successful
CI / build (push) Successful in 1m56s
CI / docker (push) Successful in 21s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 15:32:09 +02:00
hsiegeln
273baf7996 chore: use registry.cameleer.io as default image registry
All checks were successful
CI / build (push) Successful in 2m0s
CI / docker (push) Successful in 59s
Customer-facing image defaults now reference the public registry URL.
Updates installer templates and Spring Boot provisioning defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 15:24:03 +02:00
hsiegeln
5ca118dc93 docs: update CLAUDE.md for installer submodule structure
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 14s
Reflect that installer/ is now a git submodule pointing to the public
cameleer-saas-installer repo, and that docker-compose.yml is a thin
dev overlay chained via COMPOSE_FILE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 13:05:20 +02:00
hsiegeln
0b8cdf6dd9 refactor: move installer to dedicated repo, wire as git submodule
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 20s
The installer (install.sh, templates/, bootstrap scripts) now lives in
cameleer/cameleer-saas-installer (public repo). Added as a git submodule
at installer/ so compose templates remain the single source of truth.

Dev compose is now a thin overlay (ports + volume mount + dev env vars).
Production templates are chained via COMPOSE_FILE in .env:
  installer/templates/docker-compose.yml
  installer/templates/docker-compose.saas.yml
  docker-compose.yml (dev overrides)

No code duplication — fixes to compose templates go to the installer
repo and propagate to both production deployments and dev via submodule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:59:44 +02:00
hsiegeln
cafd7e9369 feat: add bootstrap scripts for one-line installer download
All checks were successful
CI / build (push) Successful in 1m39s
CI / docker (push) Successful in 18s
get-cameleer.sh and get-cameleer.ps1 download just the installer
files from Gitea into a local ./installer directory. Usage:

  curl -fsSL https://gitea.siegeln.net/.../get-cameleer.sh | bash
  irm https://gitea.siegeln.net/.../get-cameleer.ps1 | iex

Supports --version=v1.2.0 to pin a specific tag, defaults to main.
Pass --run to auto-execute the installer after download.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:48:23 +02:00
hsiegeln
b5068250f9 fix(ui): improve onboarding page styling to match sign-in page
All checks were successful
CI / build (push) Successful in 1m51s
CI / docker (push) Successful in 1m16s
Add 32px card padding and reduce max-width to 420px for consistency
with the sign-in page. Add noValidate to prevent browser-native
required validation outlines on inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:43:11 +02:00
hsiegeln
0cfa359fc5 fix(sign-in): detect register mode from URL path, not just query param
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 44s
Newer Logto versions redirect to /register?app_id=... instead of
/sign-in?first_screen=register. Check the pathname in addition to
the query param so the registration form shows correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 10:10:57 +02:00
hsiegeln
5cc9f8c9ef fix(spa): add /register and /onboarding to SPA forward routes
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 43s
These routes were missing from SpaController, so requests to
/platform/register and /platform/onboarding had no handler. Spring
forwarded to /error, which isn't in the permitAll() list, resulting
in a 401 instead of serving the SPA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 10:05:56 +02:00
hsiegeln
b066d1abe7 fix(sign-in): validate email format before registration attempt
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 46s
Show "Please enter a valid email address" when the user enters a
username instead of an email in the sign-up form, rather than letting
it hit Logto's API and returning a cryptic 400.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:41:41 +02:00
hsiegeln
ae1d9fa4db fix(docker): add extra_hosts so Logto can reach itself via public hostname
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 18s
Logto validates M2M tokens by fetching its own JWKS from the ENDPOINT
URL (e.g. https://app.cameleer.io/oidc/jwks). Behind a Cloudflare
tunnel, that hostname resolves to Cloudflare's IP and the container
can't route back through the tunnel — the fetch times out (ETIMEDOUT),
causing all Management API calls to return 500.

Adding extra_hosts maps AUTH_HOST to host-gateway so the request goes
to the Docker host, which has Traefik on :443, which routes back to
Logto internally. This hairpin works because NODE_TLS_REJECT=0 accepts
the self-signed cert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:13:39 +02:00
hsiegeln
6fe10432e6 fix(installer): remove duplicate config load that kills upgrade silently
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 15s
The upgrade path in handle_rerun called load_config_file a second time
(already called by detect_existing_install). On the second pass, every
variable is already set, so [ -z "$VAR" ] && VAR="$value" returns
exit code 1 (test fails, && short-circuits). With set -e, the non-zero
exit from the case clause kills the script silently after printing
"[INFO] Upgrading installation..." — no error, no further output.

Removed the redundant load_config_file and load_env_overrides calls.
Both were already executed in main() before handle_rerun is reached.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:03:07 +02:00
hsiegeln
9f3faf4816 fix(traefik): set Logto router priority=1 to prevent route hijacking
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 18s
Traefik auto-calculates router priority from rule string length. When
deployed with a domain longer than 23 chars (e.g. app.cameleer.io),
Host(`app.cameleer.io`) (25 chars) outranks PathPrefix(`/platform`)
(23 chars), causing ALL requests — including /platform/* — to route
to Logto instead of the SaaS app. This breaks login because the sign-in
UI loads without an OIDC interaction session.

Setting priority=1 makes Logto a true catch-all, matching the intent
documented in docker/CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 08:50:16 +02:00
hsiegeln
a60095608e fix(installer): send correct Host header in Traefik routing check
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 19s
The root redirect rule matches Host(`PUBLIC_HOST`), not localhost.
Curl with --resolve (bash) and Host header (PS1) so the health
check sends the right hostname when verifying Traefik routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 08:15:18 +02:00
hsiegeln
9f9112c6a5 feat(installer): add interactive registry prompts
Some checks failed
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 16s
SonarQube Analysis / sonarqube (push) Failing after 1m47s
Both simple and expert modes now ask "Pull images from a private
registry?" with follow-up prompts for URL, username, and token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 02:17:52 +02:00
hsiegeln
e1a9f6d225 feat(installer): add --registry, --registry-user, --registry-token
All checks were successful
CI / build (push) Successful in 1m21s
CI / docker (push) Successful in 15s
Both installers (bash + PS1) now support pulling images from a
custom Docker registry. Writes *_IMAGE env vars to .env so compose
templates use the configured registry. Runs docker login before
pull when credentials are provided. Persisted in cameleer.conf
for upgrades/reconfigure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 02:10:48 +02:00
hsiegeln
180644f0df fix(installer): SIGPIPE crash in generate_password with pipefail
All checks were successful
CI / build (push) Successful in 1m15s
CI / docker (push) Successful in 18s
`tr | head -c 32` causes tr to receive SIGPIPE when head exits early.
With `set -eo pipefail`, exit code 141 kills the script right after
"Configuration validated" before any passwords are generated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 01:41:47 +02:00
hsiegeln
62b74d2d06 ci: remove sync-images workflow
All checks were successful
CI / build (push) Successful in 1m16s
CI / docker (push) Successful in 16s
Remote server will pull directly from the Gitea registry instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 01:25:56 +02:00
hsiegeln
3e2f035d97 fix(ci): use POSIX-compatible loop instead of bash arrays
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 18s
The docker-builder container runs ash/sh, not bash — arrays with ()
are not supported. Use a simple for-in loop instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:32:12 +02:00
hsiegeln
9962ee99d9 fix(ci): drop ssh-keyscan, use StrictHostKeyChecking=accept-new instead
All checks were successful
CI / build (push) Successful in 1m16s
CI / docker (push) Successful in 17s
ssh-keyscan fails when the runner can't reach the host on port 22
during that step. Using accept-new on the ssh command itself is
equivalent for an ephemeral CI runner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:29:52 +02:00
hsiegeln
b53840b77b ci: add manual workflow to sync Docker images to remote server
Some checks failed
CI / docker (push) Has been cancelled
CI / build (push) Has been cancelled
Pulls all :latest images from the Gitea registry and pipes them
via `docker save | ssh docker load` to the APP_HOST server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:28:39 +02:00
hsiegeln
9ed2cedc98 feat: self-service sign-up with email verification and onboarding
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 1m15s
Complete sign-up pipeline: email registration via Logto Experience API,
SMTP email verification, and self-service trial tenant creation.

Layer 1 — Logto config:
- Bootstrap Phase 8b: SMTP email connector with branded HTML templates
- Bootstrap Phase 8c: enable SignInAndRegister (email+password sign-up)
- Dockerfile installs official Logto connectors (ensures SMTP available)
- SMTP env vars in docker-compose, installer templates, .env.example

Layer 2 — Experience API (ui/sign-in/experience-api.ts):
- Registration flow: initRegistration → sendVerificationCode → verifyCode
  → addProfile (password) → identifyUser → submit
- Sign-in auto-detects email vs username identifier

Layer 3 — Custom sign-in UI (ui/sign-in/SignInPage.tsx):
- Three-mode state machine: signIn / register / verifyCode
- Reads first_screen=register from URL query params
- Toggle links between sign-in and register views

Layer 4 — Post-registration onboarding:
- OnboardingService: reuses VendorTenantService.createAndProvision(),
  adds calling user to Logto org as owner, enforces one trial per user
- OnboardingController: POST /api/onboarding/tenant (authenticated only)
- OnboardingPage.tsx: org name + auto-slug form
- LandingRedirect: detects zero orgs → redirects to /onboarding
- RegisterPage.tsx: /platform/register initiates OIDC with firstScreen

Installers (install.sh + install.ps1):
- Both prompt for SMTP config in SaaS mode
- CLI args, env var capture, cameleer.conf persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:21:07 +02:00
hsiegeln
dc7ac3a1ec feat: split auth domain — Logto gets dedicated AUTH_HOST
All checks were successful
CI / build (push) Successful in 1m22s
CI / docker (push) Successful in 48s
Support separate auth domain (e.g. auth.cameleer.io) for Logto while
keeping the SaaS app on PUBLIC_HOST (e.g. app.cameleer.io). AUTH_HOST
defaults to PUBLIC_HOST for backward-compatible single-domain setups.

- Logto routing: Host(AUTH_HOST) replaces PathPrefix('/') catch-all
- Root redirect moved from traefik-dynamic.yml to Docker labels with
  Host(PUBLIC_HOST) scope so it doesn't intercept auth domain
- Self-signed cert generates SANs for both domains
- Bootstrap Host header uses AUTH_HOST for Logto endpoint validation
- Spring issuer-uri and oidcissueruri use new authhost property
- Both installers (sh + ps1) prompt for AUTH_HOST in expert mode

Local dev: AUTH_HOST=auth.localhost (resolves to 127.0.0.1, no hosts file)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 18:11:47 +02:00
147 changed files with 18410 additions and 4130 deletions

View File

@@ -7,6 +7,9 @@ VERSION=latest
# Public access
PUBLIC_HOST=localhost
PUBLIC_PROTOCOL=https
# Auth domain (Logto). Defaults to PUBLIC_HOST for single-domain setups.
# Set to a separate subdomain (e.g. auth.cameleer.io) to split auth from the app.
# AUTH_HOST=localhost
# Ports
HTTP_PORT=80
@@ -22,9 +25,14 @@ POSTGRES_DB=cameleer_saas
CLICKHOUSE_PASSWORD=change_me_in_production
# Admin user (created by bootstrap)
SAAS_ADMIN_USER=admin
# In SaaS mode, this must be an email address (primary user identity).
# In standalone mode, any username is accepted.
SAAS_ADMIN_USER=admin@example.com
SAAS_ADMIN_PASS=change_me_in_production
# SMTP / email connector configuration is managed at runtime via the vendor
# admin UI (Email Connector page at /vendor/email). No SMTP env vars needed.
# TLS (leave empty for self-signed)
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
# CERT_FILE=

View File

@@ -39,7 +39,7 @@ jobs:
- name: Build and Test (unit tests only)
run: >-
mvn clean verify -B
mvn clean verify -U -B
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java,**/VendorTenantControllerTest.java,**/TenantPortalControllerTest.java"
- name: Build sign-in UI

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "installer"]
path = installer
url = https://gitea.siegeln.net/cameleer/cameleer-saas-installer.git

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-saas** (2816 symbols, 5989 relationships, 238 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-saas** (3458 symbols, 7429 relationships, 292 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Two personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance. The vendor creates tenants, which provisions per-tenant cameleer-server + UI instances via Docker API. No example tenant — clean slate bootstrap, vendor creates everything.
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Three personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance; **new user** (authenticated, no scopes) goes through self-service onboarding. Tenants can be created by the vendor OR via self-service sign-up (email registration + onboarding wizard). Each tenant gets per-tenant cameleer-server + UI instances via Docker API.
**Email is the primary user identity** in SaaS mode. `SAAS_ADMIN_USER` IS the email address — there is no separate `SAAS_ADMIN_EMAIL`. The installer enforces email format in SaaS mode (must contain `@`; auto-appends `@<PUBLIC_HOST>` if missing). The bootstrap uses `SAAS_ADMIN_USER` as both the Logto username and primaryEmail. In standalone mode, any username is accepted. Self-service registration (email + password + verification code) is disabled by default and enabled via the vendor UI after configuring an email connector.
## Ecosystem
@@ -25,8 +27,10 @@ Agent-server protocol is defined in `cameleer/cameleer-common/PROTOCOL.md`. The
|---------|---------|-------------|
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService` |
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
| `account/` | Shared user account operations | `AccountService` (profile, password, MFA, passkeys), `AccountController` (`/api/account/*`) |
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService`, `EmailConnectorService`, `EmailConnectorController`, `VendorAuthPolicyController`, `VendorAuthPolicyEntity`, `VendorAdminService`, `VendorAdminController` |
| `onboarding/` | Self-service sign-up onboarding | `OnboardingController`, `OnboardingService` |
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService` (delegates user-level ops to AccountService), `TenantPortalController` |
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
| `license/` | License management | `LicenseService`, `LicenseController` |
@@ -46,13 +50,15 @@ For detailed architecture docs, see the directory-scoped CLAUDE.md files (loaded
- **Provisioning flow, env vars, lifecycle** → `src/.../provisioning/CLAUDE.md`
- **Auth, scopes, JWT, OIDC** → `src/.../config/CLAUDE.md`
- **Docker, routing, networks, bootstrap, deployment pipeline** → `docker/CLAUDE.md`
- **Installer, deployment modes, compose templates** → `installer/CLAUDE.md`
- **Installer, deployment modes, compose templates** → `installer/CLAUDE.md` (git submodule: `cameleer-saas-installer`)
- **Frontend, sign-in UI** → `ui/CLAUDE.md`
## Database Migrations
PostgreSQL (Flyway): `src/main/resources/db/migration/`
- V001 — consolidated baseline: tenants (with db_password, server_endpoint, provision_error, ca_applied_at), licenses, audit_log, certificates, tenant_ca_certs
- V002 — license minter: signing_keys table, tier renames, license label + grace period
- V003 — passkey MFA: vendor_auth_policy single-row config table (mfa_mode, passkey_enabled, passkey_mode)
## Related Conventions
@@ -65,7 +71,8 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
- `cameleer-server` / `cameleer-server-ui` — provisioned per-tenant (not in compose, created by `DockerTenantProvisioner`)
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + `cameleer-log-appender.jar` + JRE). CI downloads latest agent and log appender SNAPSHOTs from Gitea Maven registry. The Dockerfile ENTRYPOINT is overridden by `DockerRuntimeOrchestrator` at container creation; agent config uses `CAMELEER_AGENT_*` env vars set by `DeploymentExecutor`.
- Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility
- `docker-compose.dev.yml` — exposes ports for direct access, sets `SPRING_PROFILES_ACTIVE: dev`. Volume-mounts `./ui/dist` into the container so local UI builds are served without rebuilding the Docker image (`SPRING_WEB_RESOURCES_STATIC_LOCATIONS` overrides classpath). Adds Docker socket mount for tenant provisioning.
- `docker-compose.yml` (root) — thin dev overlay (ports, volume mounts, `SPRING_PROFILES_ACTIVE: dev`). Chained on top of production templates from the installer submodule via `COMPOSE_FILE` in `.env`.
- Installer is a **git submodule** at `installer/` pointing to `cameleer/cameleer-saas-installer` (public repo). Compose templates live there — single source of truth, no duplication. Run `git submodule update --remote installer` to pull template updates.
- Design system: import from `@cameleer/design-system` (Gitea npm registry)
## Disabled Skills
@@ -75,7 +82,7 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-saas** (2816 symbols, 5989 relationships, 238 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **vendor-admin-account** (3510 symbols, 7678 relationships, 298 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -91,7 +98,7 @@ This project is indexed by GitNexus as **cameleer-saas** (2816 symbols, 5989 rel
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/cameleer-saas/process/{processName}` — trace the full execution flow step by step
3. `READ gitnexus://repo/vendor-admin-account/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
@@ -130,10 +137,10 @@ This project is indexed by GitNexus as **cameleer-saas** (2816 symbols, 5989 rel
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/cameleer-saas/context` | Codebase overview, check index freshness |
| `gitnexus://repo/cameleer-saas/clusters` | All functional areas |
| `gitnexus://repo/cameleer-saas/processes` | All execution flows |
| `gitnexus://repo/cameleer-saas/process/{name}` | Step-by-step execution trace |
| `gitnexus://repo/vendor-admin-account/context` | Codebase overview, check index freshness |
| `gitnexus://repo/vendor-admin-account/clusters` | All functional areas |
| `gitnexus://repo/vendor-admin-account/processes` | All execution flows |
| `gitnexus://repo/vendor-admin-account/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing

View File

@@ -15,10 +15,10 @@ WORKDIR /build
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
# Cache deps — BuildKit cache mount persists across --no-cache builds
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B || true
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -U -B || true
COPY src/ src/
COPY --from=frontend /ui/dist/ src/main/resources/static/
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -U -B
# Runtime: target platform (amd64)
FROM eclipse-temurin:21-jre-alpine

View File

@@ -1,37 +0,0 @@
# Development overrides: exposes ports for direct access
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
cameleer-postgres:
ports:
- "5432:5432"
cameleer-logto:
ports:
- "3001:3001"
logto-bootstrap:
environment:
VENDOR_SEED_ENABLED: "true"
cameleer-saas:
ports:
- "8080:8080"
volumes:
- ./ui/dist:/app/static
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "0"
environment:
SPRING_PROFILES_ACTIVE: dev
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: gitea.siegeln.net/cameleer/cameleer-server:${VERSION:-latest}
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: gitea.siegeln.net/cameleer/cameleer-server-ui:${VERSION:-latest}
CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:${VERSION:-latest}
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: cameleer-saas_cameleer
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
cameleer-clickhouse:
ports:
- "8123:8123"

View File

@@ -1,158 +1,23 @@
# Dev overrides — layered on top of installer/templates/ via COMPOSE_FILE in .env
# Usage: docker compose up (reads .env automatically)
services:
cameleer-traefik:
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
- "${LOGTO_CONSOLE_PORT:-3002}:3002"
environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
CERT_FILE: ${CERT_FILE:-}
KEY_FILE: ${KEY_FILE:-}
CA_FILE: ${CA_FILE:-}
volumes:
- cameleer-certs:/certs
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- cameleer
- cameleer-traefik
cameleer-postgres:
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
volumes:
- cameleer-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer} -d ${POSTGRES_DB:-cameleer_saas}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- cameleer
ports:
- "5432:5432"
cameleer-clickhouse:
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
restart: unless-stopped
environment:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-cameleer_ch}
volumes:
- cameleer-chdata:/var/lib/clickhouse
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --password ${CLICKHOUSE_PASSWORD:-cameleer_ch} --query 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 3
labels:
- prometheus.scrape=true
- prometheus.path=/metrics
- prometheus.port=9363
networks:
- cameleer
ports:
- "8123:8123"
cameleer-logto:
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
restart: unless-stopped
depends_on:
cameleer-postgres:
condition: service_healthy
environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@cameleer-postgres:5432/logto
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
TRUST_PROXY_HEADER: 1
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
LOGTO_ENDPOINT: http://cameleer-logto:3001
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
PG_HOST: cameleer-postgres
PG_USER: ${POSTGRES_USER:-cameleer}
PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
interval: 10s
timeout: 5s
retries: 60
start_period: 30s
labels:
- traefik.enable=true
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`)
- traefik.http.routers.cameleer-logto.priority=1
- traefik.http.routers.cameleer-logto.entrypoints=websecure
- traefik.http.routers.cameleer-logto.tls=true
- traefik.http.routers.cameleer-logto.service=cameleer-logto
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
- traefik.http.services.cameleer-logto.loadbalancer.server.port=3001
- traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`)
- traefik.http.routers.cameleer-logto-console.entrypoints=admin-console
- traefik.http.routers.cameleer-logto-console.tls=true
- traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console
- traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002
volumes:
- cameleer-bootstrapdata:/data
networks:
- cameleer
ports:
- "3001:3001"
cameleer-saas:
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
restart: unless-stopped
depends_on:
cameleer-logto:
condition: service_healthy
ports:
- "8080:8080"
volumes:
- cameleer-bootstrapdata:/data/bootstrap:ro
- cameleer-certs:/certs
- /var/run/docker.sock:/var/run/docker.sock
- ./ui/dist:/app/static
environment:
# SaaS database
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/${POSTGRES_DB:-cameleer_saas}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
# Identity (Logto)
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: ${LOGTO_ENDPOINT:-http://cameleer-logto:3001}
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_IDENTITY_M2MCLIENTID: ${LOGTO_M2M_CLIENT_ID:-}
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:-cameleer-dev-jwt-secret}
# Provisioning — passed to per-tenant server containers
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer}
CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD:-cameleer_ch}
labels:
- traefik.enable=true
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
- traefik.http.routers.saas.entrypoints=websecure
- traefik.http.routers.saas.tls=true
- traefik.http.services.saas.loadbalancer.server.port=8080
group_add:
- "${DOCKER_GID:-0}"
networks:
- cameleer
networks:
cameleer:
driver: bridge
cameleer-traefik:
name: cameleer-traefik
driver: bridge
volumes:
cameleer-pgdata:
cameleer-chdata:
cameleer-certs:
cameleer-bootstrapdata:
SPRING_PROFILES_ACTIVE: dev
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/

View File

@@ -42,11 +42,13 @@ Server containers join three networks: tenant network (primary), shared services
## Custom sign-in UI (`ui/sign-in/`)
Separate Vite+React SPA replacing Logto's default sign-in page. Visually matches cameleer-server LoginPage.
Separate Vite+React SPA replacing Logto's default sign-in page. Supports both sign-in and self-service registration (registration is disabled by default until the vendor admin configures an email connector via the UI).
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + COPY dist over `/etc/logto/packages/experience/dist/`
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + install official connectors + COPY dist over `/etc/logto/packages/experience/dist/`
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
- Authenticates via Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect)
- **Sign-in**: Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect). Auto-detects email vs username identifier.
- **Registration**: 2-phase flow. Phase 1: init Register -> send verification code to email. Phase 2: verify code -> set password -> identify (creates user) -> submit -> redirect.
- Reads `first_screen=register` from URL query params to show register form initially (set by `@logto/react` SDK's `firstScreen` option)
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
@@ -78,11 +80,14 @@ Idempotent script run inside the Logto container entrypoint. **Clean slate** —
3. Create Logto apps (SPA, Traditional Web App with `skipConsent`, M2M with Management API role + server API role)
3b. Create API resource scopes (1 platform + 9 tenant + 3 server scopes)
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
5. Create admin user (SaaS admin with Logto console access)
5. Create admin user (SaaS admin with Logto console access). `SAAS_ADMIN_USER` is the admin's email address in SaaS mode — used as both the Logto username and primaryEmail. No separate `SAAS_ADMIN_EMAIL`.
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
8c. Configure sign-in experience (sign-in only) — sets `signInMode: "SignIn"` with username+password method. Registration is disabled by default; the vendor admin enables it via the Email Connector UI after configuring SMTP delivery.
9. Cleanup seeded Logto apps
10. Write bootstrap results to `/data/logto-bootstrap.json`
12. Create `saas-vendor` global role with all API scopes and assign to admin user (always runs — admin IS the platform admin).
SMTP / email connector configuration is managed at runtime via the vendor admin UI (Email Connector page). The bootstrap no longer creates email connectors — it defaults to sign-in only mode. Registration is enabled automatically when the admin configures an email connector through the UI.
The multi-tenant compose stack is: Traefik + PostgreSQL + ClickHouse + Logto (with bootstrap entrypoint) + cameleer-saas. No `cameleer-server` or `cameleer-server-ui` in compose — those are provisioned per-tenant by `DockerTenantProvisioner`.

View File

@@ -1,6 +1,14 @@
#!/bin/sh
set -e
# Build DB_URL from individual env vars so passwords with special characters
# are properly URL-encoded (Logto only accepts a connection string)
if [ -z "$DB_URL" ]; then
ENCODED_PW=$(node -e "process.stdout.write(encodeURIComponent(process.env.PG_PASSWORD || ''))")
export DB_URL="postgres://${PG_USER:-cameleer}:${ENCODED_PW}@${PG_HOST:-localhost}:5432/logto"
echo "[entrypoint] Built DB_URL from PG_USER/PG_PASSWORD/PG_HOST"
fi
# Save the real public endpoints for after bootstrap
REAL_ENDPOINT="$ENDPOINT"
REAL_ADMIN_ENDPOINT="$ADMIN_ENDPOINT"

View File

@@ -28,12 +28,20 @@ if [ ! -f "$CERTS_DIR/cert.pem" ]; then
else
# Generate self-signed certificate
HOST="${PUBLIC_HOST:-localhost}"
AUTH="${AUTH_HOST:-$HOST}"
echo "[certs] Generating self-signed certificate for $HOST..."
# Build SAN list; deduplicate when AUTH_HOST equals PUBLIC_HOST
if [ "$AUTH" = "$HOST" ]; then
SAN="DNS:$HOST,DNS:*.$HOST"
else
SAN="DNS:$HOST,DNS:*.$HOST,DNS:$AUTH,DNS:*.$AUTH"
echo "[certs] (+ auth domain: $AUTH)"
fi
openssl req -x509 -newkey rsa:4096 \
-keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \
-days 365 -nodes \
-subj "/CN=$HOST" \
-addext "subjectAltName=DNS:$HOST,DNS:*.$HOST"
-addext "subjectAltName=$SAN"
SELF_SIGNED=true
echo "[certs] Generated self-signed certificate for $HOST."
fi

View File

@@ -1,21 +1,3 @@
http:
routers:
root-redirect:
rule: "Path(`/`)"
priority: 100
entryPoints:
- websecure
tls: {}
middlewares:
- root-to-platform
service: saas@docker
middlewares:
root-to-platform:
redirectRegex:
regex: "^(https?://[^/]+)/?$"
replacement: "${1}/platform/"
permanent: false
tls:
stores:
default:

View File

@@ -25,13 +25,24 @@ API_RESOURCE_INDICATOR="https://api.cameleer.local"
API_RESOURCE_NAME="Cameleer SaaS API"
# Users (configurable via env vars)
# In SaaS mode, SAAS_ADMIN_USER is the admin's email address (e.g. admin@company.com).
# The local part (before @) is used as the Logto username; the full value as primaryEmail.
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
# Extract username (local part) for Logto — Logto rejects @ in usernames
if echo "$SAAS_ADMIN_USER" | grep -q '@'; then
ADMIN_USERNAME="${SAAS_ADMIN_USER%%@*}"
ADMIN_EMAIL="$SAAS_ADMIN_USER"
else
ADMIN_USERNAME="$SAAS_ADMIN_USER"
ADMIN_EMAIL=""
fi
# No server config — servers are provisioned dynamically by the admin console
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
HOST="${PUBLIC_HOST:-localhost}"
AUTH="${AUTH_HOST:-$HOST}"
PROTO="${PUBLIC_PROTOCOL:-https}"
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\",\"${PROTO}://${HOST}/platform/\"]"
@@ -47,8 +58,9 @@ if [ "$BOOTSTRAP_LOCAL" = "true" ]; then
HOST_ARGS=""
ADMIN_HOST_ARGS=""
else
HOST_ARGS="-H Host:${HOST}"
ADMIN_HOST_ARGS="-H Host:${HOST}:3002 -H X-Forwarded-Proto:https"
# Logto validates Host header against its ENDPOINT, which uses AUTH_HOST
HOST_ARGS="-H Host:${AUTH}"
ADMIN_HOST_ARGS="-H Host:${AUTH}:3002 -H X-Forwarded-Proto:https"
fi
# Install jq + curl if not already available (deps are baked into cameleer-logto image)
@@ -387,19 +399,27 @@ log "API resource scopes assigned to organization roles."
# ============================================================
# --- Platform Owner ---
log "Checking for platform owner user '$SAAS_ADMIN_USER'..."
ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id")
log "Checking for platform owner user '$ADMIN_USERNAME'..."
ADMIN_USER_ID=$(api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id")
if [ -n "$ADMIN_USER_ID" ]; then
log "Platform owner exists: $ADMIN_USER_ID"
else
log "Creating platform owner '$SAAS_ADMIN_USER'..."
ADMIN_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$SAAS_ADMIN_USER\",
\"password\": \"$SAAS_ADMIN_PASS\",
\"name\": \"Platform Owner\"
}")
# Build user JSON — include primaryEmail only if SAAS_ADMIN_USER is an email
ADMIN_USER_JSON="{\"username\": \"$ADMIN_USERNAME\", \"password\": \"$SAAS_ADMIN_PASS\", \"name\": \"Platform Owner\""
if [ -n "$ADMIN_EMAIL" ]; then
ADMIN_USER_JSON="$ADMIN_USER_JSON, \"primaryEmail\": \"$ADMIN_EMAIL\""
log "Creating platform owner '$ADMIN_USERNAME' (email: $ADMIN_EMAIL)..."
else
log "Creating platform owner '$ADMIN_USERNAME'..."
fi
ADMIN_USER_JSON="$ADMIN_USER_JSON}"
ADMIN_RESPONSE=$(api_post "/api/users" "$ADMIN_USER_JSON")
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
if [ -z "$ADMIN_USER_ID" ] || [ "$ADMIN_USER_ID" = "null" ]; then
log "ERROR: Failed to create platform owner. Response: $(echo "$ADMIN_RESPONSE" | head -c 300)"
else
log "Created platform owner: $ADMIN_USER_ID"
fi
fi
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
@@ -439,12 +459,12 @@ else
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
}
# Check if admin user already exists on admin tenant
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id" 2>/dev/null)
# Check if admin user already exists on admin tenant (uses ADMIN_USERNAME, not email)
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id" 2>/dev/null)
if [ -z "$ADMIN_TENANT_USER_ID" ] || [ "$ADMIN_TENANT_USER_ID" = "null" ]; then
log "Creating admin console user '$SAAS_ADMIN_USER'..."
log "Creating admin console user '$ADMIN_USERNAME'..."
ADMIN_TENANT_RESPONSE=$(admin_api_post "/api/users" "{
\"username\": \"$SAAS_ADMIN_USER\",
\"username\": \"$ADMIN_USERNAME\",
\"password\": \"$SAAS_ADMIN_PASS\",
\"name\": \"Platform Admin\"
}")
@@ -532,7 +552,15 @@ CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environme
if (role.name === "saas-vendor") roles.add("server:admin");
}
}
return roles.size > 0 ? { roles: [...roles] } : {};
const mfaFactors = context?.user?.mfaVerificationFactors || [];
const mfaEnrolled = mfaFactors.some(f => f.type === "Totp" || f.type === "WebAuthn");
const passkeyEnrolled = mfaFactors.some(f => f.type === "WebAuthn");
const claims = {};
if (roles.size > 0) claims.roles = [...roles];
claims.mfa_enrolled = mfaEnrolled;
claims.passkey_enrolled = passkeyEnrolled;
claims.mfa_method_preference = context?.user?.customData?.mfa_method_preference || null;
return claims;
};'
CUSTOM_JWT_PAYLOAD=$(jq -n --arg script "$CUSTOM_JWT_SCRIPT" '{ script: $script }')
@@ -562,6 +590,38 @@ api_patch "/api/sign-in-exp" "{
}"
log "Sign-in branding configured."
# ============================================================
# PHASE 8c: Configure sign-in experience (sign-in only)
# ============================================================
# Registration is disabled by default. The vendor admin enables it
# via the Email Connector UI after configuring SMTP delivery.
log "Configuring sign-in experience (sign-in only, no registration)..."
api_patch "/api/sign-in-exp" '{
"signInMode": "SignIn",
"signIn": {
"methods": [
{
"identifier": "email",
"password": true,
"verificationCode": false,
"isPasswordPrimary": true
},
{
"identifier": "username",
"password": true,
"verificationCode": false,
"isPasswordPrimary": true
}
]
},
"mfa": {
"factors": ["Totp", "WebAuthn", "BackupCode"],
"policy": "UserControlled"
}
}' >/dev/null 2>&1
log "Sign-in experience configured: SignIn only (registration disabled until email is configured)."
# ============================================================
# PHASE 9: Cleanup seeded apps
# ============================================================

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,449 @@
# Email Template Polish Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace inline HTML email templates with polished, branded HTML files loaded from classpath, featuring playful desert/caravan copy, structured card layout with watermark, and proper header/footer.
**Architecture:** Extract 4 email templates from `EmailConnectorService.buildSmtpConfig()` into standalone HTML files at `src/main/resources/email-templates/`. Generate a pre-faded watermark PNG served as a static asset. Inject `ProvisioningProperties` to resolve the watermark URL at runtime.
**Tech Stack:** Java 21, Spring Boot, ImageMagick (one-time asset generation), HTML email (inline styles only)
---
### File Map
| Action | File | Purpose |
|--------|------|---------|
| Create | `src/main/resources/email-templates/register.html` | Registration verification email |
| Create | `src/main/resources/email-templates/sign-in.html` | Sign-in verification email |
| Create | `src/main/resources/email-templates/forgot-password.html` | Password reset email |
| Create | `src/main/resources/email-templates/generic.html` | Generic verification email |
| Create | `src/main/resources/static/assets/email-watermark.png` | Pre-faded logo at 7% opacity |
| Modify | `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java` | Load templates from classpath, inject watermark URL |
| Modify | `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49` | Permit `/assets/**` for unauthenticated email clients |
| Create | `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java` | Verify templates load and placeholders resolve |
---
### Task 1: Generate the pre-faded watermark PNG
**Files:**
- Create: `src/main/resources/static/assets/email-watermark.png`
- [ ] **Step 1: Generate the faded watermark using ImageMagick**
Source the logo from the design-system sibling repo. Apply 7% opacity on a transparent background, output to the static assets directory:
```bash
magick "C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png" \
-channel A -evaluate Multiply 0.07 +channel \
-resize 320x320 \
"src/main/resources/static/assets/email-watermark.png"
```
If `magick` is not available, use Python Pillow as fallback:
```bash
python3 -c "
from PIL import Image
img = Image.open('C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png').convert('RGBA')
img = img.resize((320, 320), Image.LANCZOS)
r, g, b, a = img.split()
a = a.point(lambda x: int(x * 0.07))
img = Image.merge('RGBA', (r, g, b, a))
img.save('src/main/resources/static/assets/email-watermark.png')
print('Saved watermark')
"
```
- [ ] **Step 2: Verify the file exists and is reasonable size**
```bash
ls -la src/main/resources/static/assets/email-watermark.png
```
Expected: File exists, roughly 5-30 KB.
- [ ] **Step 3: Commit**
```bash
git add src/main/resources/static/assets/email-watermark.png
git commit -m "feat: add pre-faded logo watermark for email templates"
```
---
### Task 2: Permit static assets in SecurityConfig
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49`
The watermark image must be loadable by email clients without authentication. The current security config has `.anyRequest().authenticated()` as catch-all, so `/assets/**` needs an explicit permit.
- [ ] **Step 1: Add `/assets/**` to the permitAll list**
In `SecurityConfig.java`, find the existing line:
```java
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
```
Change it to:
```java
.requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
```
- [ ] **Step 2: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
git commit -m "feat: permit /assets/** for unauthenticated access (email watermark)"
```
---
### Task 3: Create the 4 HTML email template files
**Files:**
- Create: `src/main/resources/email-templates/register.html`
- Create: `src/main/resources/email-templates/sign-in.html`
- Create: `src/main/resources/email-templates/forgot-password.html`
- Create: `src/main/resources/email-templates/generic.html`
All templates use the same card structure. The `{{code}}` placeholder is Logto's built-in substitution. The `{{watermarkUrl}}` placeholder is replaced by `EmailConnectorService` at runtime.
- [ ] **Step 1: Create `register.html`**
```html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to the caravan!</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Enter this code to verify your email and claim your spot. The dunes wait for no one.</p>
<div style="text-align:center;margin:0 0 24px;">
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
</div>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>
```
- [ ] **Step 2: Create `sign-in.html`**
```html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Back at the oasis already?</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your sign-in code. The caravan master is checking credentials.</p>
<div style="text-align:center;margin:0 0 24px;">
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
</div>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>
```
- [ ] **Step 3: Create `forgot-password.html`**
```html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Lost in the dunes?</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">No worries — enter this code to reset your password and get back on the trail.</p>
<div style="text-align:center;margin:0 0 24px;">
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
</div>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>
```
- [ ] **Step 4: Create `generic.html`**
```html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Quick checkpoint</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your verification code. Just making sure it's really you at the reins.</p>
<div style="text-align:center;margin:0 0 24px;">
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
</div>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>
```
- [ ] **Step 5: Commit**
```bash
git add src/main/resources/email-templates/
git commit -m "feat: add branded HTML email templates with desert/caravan copy"
```
---
### Task 4: Refactor EmailConnectorService to load templates from classpath
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java`
- [ ] **Step 1: Write the failing test**
Create `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java`:
```java
package net.siegeln.cameleer.saas.vendor;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class EmailTemplateLoadingTest {
private static final String[] TEMPLATE_FILES = {
"email-templates/register.html",
"email-templates/sign-in.html",
"email-templates/forgot-password.html",
"email-templates/generic.html"
};
@Test
void allTemplateFilesExistOnClasspath() {
for (String path : TEMPLATE_FILES) {
var resource = new ClassPathResource(path);
assertTrue(resource.exists(), "Template file missing: " + path);
}
}
@Test
void templatesContainCodePlaceholder() throws IOException {
for (String path : TEMPLATE_FILES) {
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
assertTrue(content.contains("{{code}}"),
path + " must contain {{code}} placeholder");
}
}
@Test
void templatesContainWatermarkPlaceholder() throws IOException {
for (String path : TEMPLATE_FILES) {
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
assertTrue(content.contains("{{watermarkUrl}}"),
path + " must contain {{watermarkUrl}} placeholder");
}
}
@Test
void watermarkPlaceholderIsReplaced() throws IOException {
String content = new ClassPathResource("email-templates/register.html")
.getContentAsString(StandardCharsets.UTF_8);
String resolved = content.replace("{{watermarkUrl}}",
"https://example.com/platform/assets/email-watermark.png");
assertFalse(resolved.contains("{{watermarkUrl}}"));
assertTrue(resolved.contains("https://example.com/platform/assets/email-watermark.png"));
}
@Test
void templatesContainBrandElements() throws IOException {
for (String path : TEMPLATE_FILES) {
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
assertTrue(content.contains("Cameleer.io"),
path + " must contain Cameleer.io header");
assertTrue(content.contains("Apache Camel observability"),
path + " must contain tagline");
assertTrue(content.contains("#C6820E"),
path + " must use brand color");
}
}
}
```
- [ ] **Step 2: Run tests to verify they pass (templates exist from Task 3)**
```bash
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
```
Expected: All 5 tests PASS.
- [ ] **Step 3: Add `ProvisioningProperties` dependency to `EmailConnectorService`**
Replace the constructor and add the template loading logic. The full updated `EmailConnectorService.java`:
Change the imports and fields at the top of the class — add `ProvisioningProperties` import and field:
```java
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
```
Replace the constructor:
```java
private final LogtoManagementClient logtoClient;
private final ProvisioningProperties provisioningProps;
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
this.logtoClient = logtoClient;
this.provisioningProps = provisioningProps;
}
```
Replace the `buildSmtpConfig` method (lines 157-191) with:
```java
/** Load an email template from classpath and resolve the watermark URL placeholder. */
private String loadTemplate(String filename) {
try {
String content = new ClassPathResource("email-templates/" + filename)
.getContentAsString(StandardCharsets.UTF_8);
String watermarkUrl = provisioningProps.publicProtocol() + "://"
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
return content.replace("{{watermarkUrl}}", watermarkUrl);
} catch (IOException e) {
throw new IllegalStateException("Failed to load email template: " + filename, e);
}
}
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
var config = new HashMap<String, Object>();
config.put("host", smtp.host());
config.put("port", smtp.port());
config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password()));
config.put("fromEmail", smtp.fromEmail());
config.put("templates", List.of(
Map.of(
"usageType", "Register",
"contentType", "text/html",
"subject", "Your caravan pass is almost ready",
"content", loadTemplate("register.html")
),
Map.of(
"usageType", "SignIn",
"contentType", "text/html",
"subject", "Your Cameleer sign-in code",
"content", loadTemplate("sign-in.html")
),
Map.of(
"usageType", "ForgotPassword",
"contentType", "text/html",
"subject", "Reset your Cameleer password",
"content", loadTemplate("forgot-password.html")
),
Map.of(
"usageType", "Generic",
"contentType", "text/html",
"subject", "Your Cameleer verification code",
"content", loadTemplate("generic.html")
)
));
return config;
}
```
- [ ] **Step 4: Verify the project compiles**
```bash
./mvnw compile -pl .
```
Expected: BUILD SUCCESS
- [ ] **Step 5: Run the template tests again to confirm nothing broke**
```bash
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
```
Expected: All 5 tests PASS.
- [ ] **Step 6: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
git add src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
git commit -m "feat: load email templates from classpath with watermark URL resolution"
```
---
### Task 5: Run the full test suite
**Files:** None (verification only)
- [ ] **Step 1: Run all tests**
```bash
./mvnw test -Dspring.profiles.active=test
```
Expected: BUILD SUCCESS, all tests pass. If any existing tests fail due to the new `ProvisioningProperties` constructor parameter on `EmailConnectorService`, they will need their mocks updated — but there are no existing tests for this class.
- [ ] **Step 2: Verify the watermark is accessible without auth by checking SecurityConfig**
Confirm the `/assets/**` matcher is in the `permitAll()` chain (done in Task 2). With context-path `/platform`, the full public URL will be `https://<host>/platform/assets/email-watermark.png`.
- [ ] **Step 3: Final commit if any fixes were needed**
Only if test failures required changes:
```bash
git add -A
git commit -m "fix: resolve test failures from email template refactor"
```

View File

@@ -0,0 +1,614 @@
# License Minter Integration — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace UUID-based license tokens with Ed25519-signed tokens minted by `cameleer-license-minter`, with full vendor UI for configurable minting, distribution, and verification.
**Architecture:** The SaaS platform embeds `cameleer-license-minter` as a Maven dependency and calls `LicenseMinter.mint()` with an Ed25519 private key stored in the DB. Signed tokens are pushed to tenant servers via env vars and REST API. The vendor UI provides tier presets with per-limit customization, copy/email distribution as env-var bundles, and a token verification tool.
**Tech Stack:** Spring Boot 3.4, JPA/Flyway/PostgreSQL, Ed25519 (JCE), `cameleer-license-minter` + `cameleer-server-core` (LicenseInfo, LicenseValidator), React 19, @cameleer/design-system, TanStack Query.
**Decisions:**
- Tiers renamed: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
- Tiers are presets only — vendor can customize any limit (becomes "Custom" in UI)
- Private key stored in DB (signing_keys table)
- Features concept dropped — server enforces caps, not feature flags
- Standalone distribution: license bundle = token + public key + tenant ID as env vars
- Verify tool: paste token → decode + validate signature → show envelope + state
---
## Phase 1: Backend Foundation
### Task 1: Maven dependency + Flyway migration
**Files:**
- Modify: `pom.xml`
- Create: `src/main/resources/db/migration/V002__license_minter.sql`
- [ ] **Step 1: Add minter dependency to pom.xml**
Add inside `<dependencies>`:
```xml
<!-- License Minter (Ed25519 signing) -->
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-license-minter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
This transitively brings `cameleer-server-core` (for `LicenseInfo`, `LicenseValidator`).
- [ ] **Step 2: Create Flyway V002 migration**
```sql
-- V002: License minter integration
-- Signing keys for Ed25519 license minting
CREATE TABLE signing_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
public_key_b64 TEXT NOT NULL,
private_key_b64 TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Rename tiers: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
UPDATE tenants SET tier = 'STARTER' WHERE tier = 'LOW';
UPDATE tenants SET tier = 'TEAM' WHERE tier = 'MID';
UPDATE tenants SET tier = 'BUSINESS' WHERE tier = 'HIGH';
UPDATE tenants SET tier = 'ENTERPRISE' WHERE tier = 'BUSINESS';
-- Fix double-rename: HIGH→BUSINESS rows that got caught by BUSINESS→ENTERPRISE
-- Use a single pass via CASE to avoid this:
-- Actually, redo with CASE statement in a single UPDATE:
-- (Replace the 4 UPDATEs above with this single safe statement)
UPDATE tenants SET tier = CASE tier
WHEN 'LOW' THEN 'STARTER'
WHEN 'MID' THEN 'TEAM'
WHEN 'HIGH' THEN 'BUSINESS'
WHEN 'BUSINESS' THEN 'ENTERPRISE'
ELSE tier
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
-- Same for licenses table
UPDATE licenses SET tier = CASE tier
WHEN 'LOW' THEN 'STARTER'
WHEN 'MID' THEN 'TEAM'
WHEN 'HIGH' THEN 'BUSINESS'
WHEN 'BUSINESS' THEN 'ENTERPRISE'
ELSE tier
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
-- Add new license columns
ALTER TABLE licenses ADD COLUMN label VARCHAR(255);
ALTER TABLE licenses ADD COLUMN grace_period_days INTEGER NOT NULL DEFAULT 0;
-- Drop features column (server enforces caps, not feature flags)
ALTER TABLE licenses DROP COLUMN features;
```
- [ ] **Step 3: Verify build compiles**
Run: `mvn compile -q` (just compile, no tests yet — tests will break until Tier enum is updated)
- [ ] **Step 4: Commit**
```
feat: add cameleer-license-minter dependency and V002 migration
Adds Ed25519 license minting library, signing_keys table,
renames tiers (LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE),
adds label + grace_period_days to licenses, drops features column.
```
### Task 2: Tier enum rename + LicenseDefaults rewrite
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java`
- [ ] **Step 1: Update Tier enum**
```java
package net.siegeln.cameleer.saas.tenant;
public enum Tier {
STARTER, TEAM, BUSINESS, ENTERPRISE
}
```
- [ ] **Step 2: Update TenantEntity default**
Change `private Tier tier = Tier.LOW;` to `private Tier tier = Tier.STARTER;`
- [ ] **Step 3: Update TenantService fallback**
Change `Tier.valueOf(request.tier()) : Tier.LOW` to `Tier.valueOf(request.tier()) : Tier.STARTER`
- [ ] **Step 4: Rewrite LicenseDefaults**
Replace entire file with 13-key limits per tier matching the handoff cap matrix. Drop `featuresForTier()`. Only `limitsForTier()`.
```java
package net.siegeln.cameleer.saas.license;
import net.siegeln.cameleer.saas.tenant.Tier;
import java.util.Map;
public final class LicenseDefaults {
private LicenseDefaults() {}
public static final int DEFAULT_GRACE_PERIOD_DAYS = 14;
public static final int DEFAULT_LICENSE_DAYS = 365;
public static Map<String, Integer> limitsForTier(Tier tier) {
return switch (tier) {
case STARTER -> Map.ofEntries(
Map.entry("max_environments", 2),
Map.entry("max_apps", 10),
Map.entry("max_agents", 20),
Map.entry("max_users", 5),
Map.entry("max_outbound_connections", 5),
Map.entry("max_alert_rules", 10),
Map.entry("max_total_cpu_millis", 8000),
Map.entry("max_total_memory_mb", 8192),
Map.entry("max_total_replicas", 25),
Map.entry("max_execution_retention_days", 7),
Map.entry("max_log_retention_days", 7),
Map.entry("max_metric_retention_days", 7),
Map.entry("max_jar_retention_count", 5)
);
case TEAM -> Map.ofEntries(
Map.entry("max_environments", 5),
Map.entry("max_apps", 50),
Map.entry("max_agents", 100),
Map.entry("max_users", 25),
Map.entry("max_outbound_connections", 25),
Map.entry("max_alert_rules", 50),
Map.entry("max_total_cpu_millis", 32000),
Map.entry("max_total_memory_mb", 32768),
Map.entry("max_total_replicas", 100),
Map.entry("max_execution_retention_days", 30),
Map.entry("max_log_retention_days", 30),
Map.entry("max_metric_retention_days", 30),
Map.entry("max_jar_retention_count", 10)
);
case BUSINESS -> Map.ofEntries(
Map.entry("max_environments", 10),
Map.entry("max_apps", 200),
Map.entry("max_agents", 500),
Map.entry("max_users", 100),
Map.entry("max_outbound_connections", 100),
Map.entry("max_alert_rules", 200),
Map.entry("max_total_cpu_millis", 128000),
Map.entry("max_total_memory_mb", 131072),
Map.entry("max_total_replicas", 500),
Map.entry("max_execution_retention_days", 90),
Map.entry("max_log_retention_days", 90),
Map.entry("max_metric_retention_days", 90),
Map.entry("max_jar_retention_count", 25)
);
case ENTERPRISE -> Map.ofEntries(
Map.entry("max_environments", 50),
Map.entry("max_apps", 1000),
Map.entry("max_agents", 5000),
Map.entry("max_users", 1000),
Map.entry("max_outbound_connections", 500),
Map.entry("max_alert_rules", 1000),
Map.entry("max_total_cpu_millis", 512000),
Map.entry("max_total_memory_mb", 524288),
Map.entry("max_total_replicas", 2000),
Map.entry("max_execution_retention_days", 365),
Map.entry("max_log_retention_days", 180),
Map.entry("max_metric_retention_days", 180),
Map.entry("max_jar_retention_count", 50)
);
};
}
}
```
- [ ] **Step 5: Commit**
```
refactor: rename tiers and rewrite LicenseDefaults to 13-key cap matrix
```
### Task 3: SigningKeyService + SigningKeyEntity
**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java`
- [ ] **Step 1: Create SigningKeyEntity**
JPA entity for the `signing_keys` table: id (UUID), publicKeyB64 (text), privateKeyB64 (text), active (boolean), createdAt (Instant).
- [ ] **Step 2: Create SigningKeyRepository**
JpaRepository with `Optional<SigningKeyEntity> findByActiveTrue()`.
- [ ] **Step 3: Create SigningKeyService**
Methods:
- `getOrCreateActiveKey()` → returns the active key, generating a new Ed25519 keypair on first call
- `getPublicKeyBase64()` → convenience for the active key's public key
- `getPrivateKey()` → reconstructs `PrivateKey` from stored base64
Key generation:
```java
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
```
Private key reconstruction:
```java
byte[] keyBytes = Base64.getDecoder().decode(entity.getPrivateKeyB64());
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
```
- [ ] **Step 4: Commit**
```
feat: add SigningKeyService for Ed25519 keypair management
```
### Task 4: Rewrite LicenseService + LicenseEntity
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java`
- [ ] **Step 1: Update LicenseEntity**
- Remove `features` field + getter/setter
- Add `label` (String) field + getter/setter
- Add `gracePeriodDays` (int) field + getter/setter
- [ ] **Step 2: Rewrite LicenseService**
- Add `SigningKeyService` dependency
- Rewrite `generateLicense(TenantEntity, Map<String,Integer> limits, Instant expiresAt, int gracePeriodDays, String label, UUID actorId)`:
- Build `LicenseInfo(UUID.randomUUID(), tenant.getSlug(), label, limits, Instant.now(), expiresAt, gracePeriodDays)`
- Call `LicenseMinter.mint(info, signingKeyService.getPrivateKey())`
- Store signed token in entity
- Add convenience overload `generateLicense(TenantEntity, Duration, UUID actorId)` that uses tier presets
- Remove `verifyLicenseToken()` (server validates cryptographically)
- Add `verifyToken(String token)` that uses `LicenseValidator`
- [ ] **Step 3: Update LicenseResponse DTO**
Replace `features` with `label` and `gracePeriodDays`. Add `publicKeyB64` for bundle distribution.
- [ ] **Step 4: Commit**
```
feat: rewrite LicenseService to mint Ed25519-signed tokens
```
### Task 5: Update controllers + portal service
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java`
- [ ] **Step 1: Update VendorTenantController**
- `POST /{id}/license` now takes a request body with limits, expiresAt, gracePeriodDays, label
- Add `GET /license-presets` endpoint returning tier presets
- Add `POST /license/verify` endpoint
- Add `GET /signing-key/public` endpoint
- [ ] **Step 2: Update VendorTenantService**
- `renewLicense()` updated to accept customizable parameters
- Add `mintLicense()` method with full limit configuration
- Add `verifyToken()` delegation
- [ ] **Step 3: Update VendorTenantController response types**
- `VendorTenantSummary` — fix `agentLimit` to use `max_agents` key
- `VendorTenantDetail` — license field uses updated LicenseResponse
- [ ] **Step 4: Update TenantPortalService**
- `DashboardData` — drop features, keep limits
- `LicenseData` — drop features, add label + gracePeriodDays
- [ ] **Step 5: Commit**
```
feat: update vendor/portal APIs for Ed25519 license minting
```
### Task 6: Fix tests
**Files:**
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java`
- [ ] **Step 1: Update all Tier.LOW→STARTER, Tier.MID→TEAM, Tier.HIGH→BUSINESS, Tier.BUSINESS→ENTERPRISE**
- [ ] **Step 2: Update LicenseServiceTest**
- `generateLicense_producesUuidToken` → rename to `generateLicense_producesSignedToken`, assert token contains `.` separator
- Remove feature-related assertions
- Mock `SigningKeyService` to return a test keypair
- Remove `verifyLicenseToken` tests
- [ ] **Step 3: Update LicenseControllerTest**
- Remove feature assertions (`features.correlation`)
- Update tier values in assertions
- [ ] **Step 4: Run tests**
Run: `mvn test -q`
- [ ] **Step 5: Commit**
```
test: update tests for Ed25519 license minting and tier rename
```
## Phase 2: Provisioning Integration
### Task 7: Push public key to tenant containers
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
- [ ] **Step 1: Inject SigningKeyService into DockerTenantProvisioner**
Add `SigningKeyService` as a constructor dependency.
- [ ] **Step 2: Add CAMELEER_SERVER_LICENSE_PUBLICKEY env var**
In `createServerContainer()`, after the existing env vars, add:
```java
"CAMELEER_SERVER_LICENSE_PUBLICKEY=" + signingKeyService.getPublicKeyBase64()
```
`CAMELEER_SERVER_TENANT_ID` is already set to slug (line 218).
`CAMELEER_SERVER_LICENSE_TOKEN` is already set (line 225).
- [ ] **Step 3: Commit**
```
feat: push Ed25519 public key to tenant server containers
```
## Phase 3: Vendor API — Configurable Minting
### Task 8: Vendor license endpoints
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java`
- [ ] **Step 1: Create DTOs**
`MintLicenseRequest`: tier (optional String), limits (Map<String,Integer>), expiresAt (Instant), gracePeriodDays (Integer), label (String), pushToServer (boolean)
`VerifyLicenseRequest`: token (String)
`VerifyLicenseResponse`: valid (boolean), state (String), envelope fields (tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays), error (String)
`LicensePreset`: tier (String), limits (Map<String,Integer>)
`LicenseBundleResponse`: extends LicenseResponse + adds publicKeyB64, tenantSlug (for the env-var bundle)
- [ ] **Step 2: Update VendorTenantService**
Add `mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId)`:
- Resolves limits from request (or tier preset)
- Calls `licenseService.generateLicense()` with full params
- Optionally pushes to server
- Returns the license + public key + slug for the bundle
Add `verifyToken(String token)`:
- Uses LicenseValidator from server-core
- [ ] **Step 3: Update VendorTenantController**
- `POST /{id}/license` — takes MintLicenseRequest body, returns LicenseBundleResponse
- `GET /license-presets` — returns list of LicensePreset
- `POST /license/verify` — takes VerifyLicenseRequest, returns VerifyLicenseResponse
- `GET /signing-key/public` — returns `{"publicKey": "<base64>"}`
- [ ] **Step 4: Commit**
```
feat: add vendor license minting, presets, and verify endpoints
```
## Phase 4: Vendor UI — License Minting
### Task 9: Update frontend types + hooks
**Files:**
- Modify: `ui/src/types/api.ts`
- Modify: `ui/src/api/vendor-hooks.ts`
- [ ] **Step 1: Update types**
- `LicenseResponse` — remove `features`, add `label`, `gracePeriodDays`, `publicKeyB64`, `tenantSlug`
- Add `MintLicenseRequest`, `VerifyLicenseRequest`, `VerifyLicenseResponse`, `LicensePreset`, `LicenseBundleResponse`
- `DashboardData` — remove `features`
- `TenantLicenseData` — remove `features`, add `label`, `gracePeriodDays`
- [ ] **Step 2: Update hooks**
- `useRenewLicense()` → replace with `useMintLicense(tenantId)` that takes MintLicenseRequest body
- Add `useLicensePresets()`
- Add `useVerifyLicense()`
- Add `usePublicKey()`
- [ ] **Step 3: Commit**
```
feat(ui): update types and hooks for Ed25519 license minting
```
### Task 10: License minting form on TenantDetailPage
**Files:**
- Modify: `ui/src/pages/vendor/TenantDetailPage.tsx`
- [ ] **Step 1: Replace License card**
Replace the simple "Renew License" button with a minting form:
- Tier preset dropdown (STARTER/TEAM/BUSINESS/ENTERPRISE) that pre-fills limits
- All 13 limits editable in a grid
- Expiry date picker, grace period input, label input
- "Custom" indicator when limits diverge from preset
- Actions: "Mint & Push to Server" (default), "Mint & Copy Bundle", "Mint & Email Bundle"
- [ ] **Step 2: License bundle display**
After minting, show a dialog/card with the full env-var bundle:
```
CAMELEER_SERVER_TENANT_ID=<slug>
CAMELEER_SERVER_LICENSE_PUBLICKEY=<public_key>
CAMELEER_SERVER_LICENSE_TOKEN=<token>
```
With a "Copy Bundle" button.
- [ ] **Step 3: Commit**
```
feat(ui): add license minting form with tier presets and bundle distribution
```
### Task 11: License verify tool + public key viewer
**Files:**
- Create: `ui/src/pages/vendor/LicenseVerifyPage.tsx`
- Modify: `ui/src/router.tsx` (add route)
- Modify: `ui/src/Layout.tsx` (add nav item)
- [ ] **Step 1: Create LicenseVerifyPage**
- Textarea to paste a token
- "Verify" button
- Results: valid/invalid badge, decoded envelope (tenantId, label, limits, expiry, grace period)
- State badge (ACTIVE/GRACE/EXPIRED/INVALID)
- Public key display section with copy button
- [ ] **Step 2: Add route and navigation**
Route: `/vendor/license-verify`
Nav: "License Tools" section in vendor sidebar
- [ ] **Step 3: Commit**
```
feat(ui): add license verify tool and public key viewer
```
### Task 12: Update tier color utility
**Files:**
- Modify: `ui/src/utils/tier.ts`
- [ ] **Step 1: Update tierColor**
```typescript
export function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' {
switch (tier?.toUpperCase()) {
case 'ENTERPRISE': return 'success';
case 'BUSINESS': return 'primary';
case 'TEAM': return 'running';
case 'STARTER': return 'warning';
default: return 'auto';
}
}
```
- [ ] **Step 2: Commit**
```
fix(ui): update tier color mapping for renamed tiers
```
## Phase 5: Tenant UI Updates
### Task 13: Update TenantLicensePage
**Files:**
- Modify: `ui/src/pages/tenant/TenantLicensePage.tsx`
- [ ] **Step 1: Remove features card, update limits card**
- Drop the "Features" card entirely
- Update "Limits & Usage" card to show all 13 limit keys with proper labels
- Show grace period and label if present
- [ ] **Step 2: Commit**
```
feat(ui): update tenant license page for Ed25519 model
```
### Task 14: Update TenantDashboardPage
**Files:**
- Modify: `ui/src/pages/tenant/TenantDashboardPage.tsx`
- [ ] **Step 1: Remove features references**
Drop any `features` display. Keep limits display.
- [ ] **Step 2: Commit**
```
fix(ui): remove features from tenant dashboard
```
### Task 15: Update CreateTenantPage
**Files:**
- Modify: `ui/src/pages/vendor/CreateTenantPage.tsx`
- [ ] **Step 1: Update tier options**
Change tier dropdown options from LOW/MID/HIGH/BUSINESS to STARTER/TEAM/BUSINESS/ENTERPRISE.
- [ ] **Step 2: Commit**
```
fix(ui): update tier options in create tenant form
```
---
## Verification
After all tasks:
- [ ] `mvn test` passes
- [ ] `cd ui && npm run build` succeeds
- [ ] Docker compose boots (if available)
- [ ] Verify a tenant can be created with STARTER tier
- [ ] Verify license is minted with Ed25519 signature (token contains `.`)
- [ ] Verify CAMELEER_SERVER_LICENSE_PUBLICKEY appears in container env

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
# Email Connector UI Configuration
Move email connector setup from the one-shot installer/bootstrap into the vendor admin UI, giving platform admins runtime control over email delivery and self-service registration.
## Context
The current flow bakes SMTP configuration into the installer prompts and the Logto bootstrap script. This has two problems: (1) the bootstrap factory selection regex doesn't match the actual Logto SMTP factory ID (`simple-mail-transfer-protocol`), causing it to pick the wrong factory and fail silently; (2) bootstrap is a one-shot — if SMTP is added or changed after first boot, the connector is never created or updated.
Moving configuration to the UI fixes both issues and gives admins the ability to configure, test, change, or remove email delivery at any time.
## Design Decisions
- **SMTP only for now**, but the architecture supports adding other providers (SES, SendGrid, Mailgun, etc.) with one form component and one service method per provider.
- **Registration is disabled by default** until email is configured. Admins get a toggle to enable/disable registration independently once email works.
- **Test email sends a real email** to a recipient address the admin provides, proving end-to-end delivery.
- **Email templates are hardcoded** — four Cameleer-branded HTML templates (Register, SignIn, ForgotPassword, Generic) attached automatically when saving config.
- **Email config lives under an expandable "Identity" sidebar section**, replacing the flat external Logto link. The section contains "Email Connector" and "Logto Console" (external link).
## Section 1: Removal
### Installer — bash (`installer/install.sh`)
- Remove SMTP prompt block (~lines 499-509): `prompt_yesno`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`
- Remove SMTP vars from `.env` generation
- Remove SMTP vars from `cameleer.conf` persistence
### Installer — PowerShell (`installer/install.ps1`)
- Remove env var reads (lines 95-99): `$_ENV_SMTP_HOST` through `$_ENV_SMTP_FROM_EMAIL`
- Remove config file parsing (lines 305-309): `smtp_host` through `smtp_from_email` cases
- Remove env fallback merging (lines 342-346): `if (-not $c.SmtpHost)` blocks
- Remove SMTP prompt block (lines 516-523)
- Remove SMTP `.env` output (lines 778-782, 789)
- Remove SMTP `cameleer.conf` output (lines 1028-1031, 1036)
### Compose template (`installer/templates/docker-compose.saas.yml`)
- Remove the 5 SMTP env vars from the cameleer-logto service (lines 30-35): `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`
### Bootstrap (`docker/logto-bootstrap.sh`)
- Remove Phase 8b entirely (lines 568-649): SMTP connector creation via `/api/connector-factories` and `/api/connectors`
- Modify Phase 8c (lines 657-682): Change `signInMode` from `"SignInAndRegister"` to `"SignIn"`. Remove `signUp.identifiers: ["email"]` and `signUp.verify: true`. Keep username+password sign-in method for the admin user. Registration gets enabled by the backend when the admin configures email.
## Section 2: Backend — Email Connector API
### New controller: `EmailConnectorController`
Location: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java`
`@RestController`, `@RequestMapping("/api/vendor/email-connector")`, `@PreAuthorize("hasAuthority('SCOPE_platform:admin')")`
Endpoints:
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/vendor/email-connector` | Returns current email connector config (password masked) + registration enabled state. 404 if none configured. |
| POST | `/api/vendor/email-connector` | Creates or updates connector. Accepts SMTP config + optional `registrationEnabled` boolean. Attaches branded templates. Enables registration on first save unless explicitly set to false. |
| DELETE | `/api/vendor/email-connector` | Removes connector, force-disables registration. |
| POST | `/api/vendor/email-connector/test` | Accepts `{to: "email"}`, sends test email through configured connector, returns success/failure with message. |
### New service: `EmailConnectorService`
Location: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java`
Responsibilities:
- Maps provider-specific DTOs to Logto connector config format
- Selects the correct Logto factory ID per provider (SMTP = `simple-mail-transfer-protocol`)
- Hardcodes the four Cameleer-branded HTML email templates (Register, SignIn, ForgotPassword, Generic) with `{{code}}` placeholder and `#C6820E` brand color
- Manages sign-in experience toggle via `PATCH /api/sign-in-exp`
- Handles test email flow
### New methods on `LogtoManagementClient`
Location: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java`
Following the existing SSO connector method patterns:
- `listConnectorFactories()``GET /api/connector-factories`
- `listConnectors()``GET /api/connectors`
- `createConnector(factoryId, config)``POST /api/connectors`
- `updateConnector(connectorId, config)``PATCH /api/connectors/{id}`
- `deleteConnector(connectorId)``DELETE /api/connectors/{id}`
- `testConnector(factoryId, email, config)``POST /api/connectors/{factoryId}/test` (Logto's built-in test endpoint; sends a real email with the provided config without needing to save first)
- `updateSignInExperience(config)``PATCH /api/sign-in-exp`
- `getSignInExperience()``GET /api/sign-in-exp`
## Section 3: Frontend — Email Configuration Page
### New page: `EmailConfigPage.tsx`
Location: `ui/src/pages/vendor/EmailConfigPage.tsx`
Follows the CertificatesPage pattern (Card layout, form fields, mutation hooks, toast notifications).
**Three UI states:**
| Email configured | Registration toggle | signInMode |
|---|---|---|
| No | Disabled, off | `SignIn` |
| Yes | On (default after first save) | `SignInAndRegister` |
| Yes | Off (admin chose to disable) | `SignIn` |
**Unconfigured state:**
- Info alert: "Email delivery is not configured. Self-service registration is disabled."
- SMTP form: Host (text), Port (number, default 587), Username (text), Password (password), From Email (email). All required.
- Save button.
**Configured state:**
- Card showing current config: host, port, username, from-email. Password masked as `••••••••`.
- Registration toggle (switch) with label "Enable self-service registration".
- Edit button to modify config, Delete button with confirmation dialog warning that removal disables registration.
- "Send Test Email" section: text input for recipient + Send button. Success/failure shown inline.
### New hooks: `email-connector-hooks.ts`
Location: `ui/src/api/email-connector-hooks.ts`
Following the certificate-hooks pattern:
- `useEmailConnector()``GET /api/vendor/email-connector`
- `useSaveEmailConnector()``POST /api/vendor/email-connector`
- `useDeleteEmailConnector()``DELETE /api/vendor/email-connector`
- `useTestEmailConnector()``POST /api/vendor/email-connector/test`
### Router (`router.tsx`)
- Add `/vendor/email` route inside the vendor `RequireScope` guard
### Sidebar (`Layout.tsx`)
- Replace the flat "Identity (Logto)" external link with an expandable "Identity" section
- Items: "Email Connector" (internal link to `/vendor/email`), "Logto Console" (external link, preserved)
## Section 4: Extensibility — Adding Future Providers
To add a new email provider (e.g. AWS SES):
1. **Backend**: Add a request DTO and a mapping method in `EmailConnectorService` that maps to the provider's Logto config schema and returns the correct factory ID
2. **Frontend**: Add a `SesConfigForm.tsx` component and a new option in the provider selector dropdown on `EmailConfigPage`
No changes needed to:
- `EmailConnectorController` (provider-agnostic endpoints)
- `LogtoManagementClient` (works with any factory/connector)
- Email templates (shared across providers)
- Registration toggle logic (shared across providers)
- React Query hooks (provider-agnostic)

View File

@@ -0,0 +1,108 @@
# Email Template Polish
Polish the 4 Logto SMTP connector email templates with branded visuals, playful desert/caravan copy, and extract them from inline Java strings to standalone HTML files.
## Current State
All 4 email templates are hardcoded as inline HTML strings in `EmailConnectorService.buildSmtpConfig()` (lines 164-189). They share a minimal structure: centered text "Cameleer" wordmark in `#C6820E`, a one-line message, a large verification code, and a small expiry note. No logo, no footer, no personality.
## Design Decisions
- **Tone:** Playful desert/caravan voice matching the sign-in page personality
- **Layout:** Structured card — amber header bar, white body with watermark, footer with separator
- **Footer:** Help link ("Questions? Contact your administrator") + tagline ("Cameleer — Apache Camel observability")
- **Header:** Text-only "Cameleer.io" centered on amber `#C6820E` bar, no logo image
- **Watermark:** Cameleer logo at ~7% opacity, oversized, positioned top-right showing compass + cameleer + camel head. For production: pre-faded PNG hosted at a public URL to avoid CSS opacity issues in Outlook desktop.
- **Storage:** External HTML template files, not inline Java strings
## Template Structure
All 4 templates share the same layout, differing only in subject, headline, body copy, and safety note.
```
+------------------------------------------+
| [amber #C6820E header bar] |
| Cameleer.io (white) |
+------------------------------------------+
| |
| Headline (bold, 16px) [watermark |
| Body text (14px, 1.6lh) logo at |
| 7% opacity|
| +-------------------+ |
| | VERIFICATION CODE | |
| | cream pill, amber | |
| | border, monospace | |
| +-------------------+ |
| |
| Expiry/safety note (13px, muted) |
| |
+------------------------------------------+
| Questions? Contact your administrator |
| Cameleer — Apache Camel observability |
+------------------------------------------+
```
## Copy
| Type | Subject | Headline | Body | Safety note |
|------|---------|----------|------|-------------|
| Register | Your caravan pass is almost ready | Welcome to the caravan! | Enter this code to verify your email and claim your spot. The dunes wait for no one. | This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed. |
| SignIn | Your Cameleer sign-in code | Back at the oasis already? | Here's your sign-in code. The caravan master is checking credentials. | This code expires in 10 minutes. |
| ForgotPassword | Reset your Cameleer password | Lost in the dunes? | No worries — enter this code to reset your password and get back on the trail. | This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email. |
| Generic | Your Cameleer verification code | Quick checkpoint | Here's your verification code. Just making sure it's really you at the reins. | This code expires in 10 minutes. |
## Visual Specifications
- **Font stack:** `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`
- **Card max-width:** 480px, no fixed width (shrinks naturally on mobile)
- **Card border:** `1px solid #e8e0d4`
- **Header:** `background: #C6820E`, padding 20px 24px, text centered, 22px bold white
- **Code pill:** `background: #FDF6EC`, `border: 2px solid #C6820E`, border-radius 8px, padding 16px 32px, 32px bold monospace with 8px letter-spacing in `#C6820E`
- **Watermark:** Absolutely positioned `top:-30px; right:-50px`, 320x320px, 7% opacity, clipped by `overflow: hidden` on body container
- **Footer separator:** `1px solid #e8e0d4`
- **Footer text:** "Questions?" at 12px `#999`, tagline at 11px `#bbb`
## Responsiveness
No media queries needed. The single-column layout works from ~280px up:
- Card uses `max-width: 480px` with no fixed width
- Code pill uses `display: inline-block`, wraps within container
- All sizes in px (email clients handle rem/em inconsistently)
- Watermark clipped by `overflow: hidden`, never causes horizontal scroll
## File Structure
### Template files
Create 4 HTML template files at `src/main/resources/email-templates/`:
```
src/main/resources/email-templates/
register.html
sign-in.html
forgot-password.html
generic.html
```
Each file is a complete HTML email body (inline styles, self-contained). The verification code placeholder uses Logto's `{{code}}` syntax.
### Watermark image
Create a pre-faded PNG of the Cameleer logo at 7% opacity on a transparent background. Source the logo from `design-system/assets/cameleer-logo.png` and generate the faded version using ImageMagick or similar (one-time step, committed to the repo).
Place the image at `src/main/resources/static/platform/assets/email-watermark.png`. Spring Boot serves `/platform/assets/**` as static resources automatically. The template files use a placeholder `{{watermarkUrl}}` that `EmailConnectorService` replaces with `https://<PUBLIC_HOST>/platform/assets/email-watermark.png` at runtime.
### Java changes
`EmailConnectorService.buildSmtpConfig()`:
- Read each template file from classpath (`src/main/resources/email-templates/*.html`) at startup or on first use
- Replace `{{watermarkUrl}}` with the configured public host URL
- Pass the HTML content as the `content` field in each Logto template config
- Keep subjects in Java (they're short strings, no benefit from externalizing)
## Email Client Compatibility
- **Gmail, Apple Mail, Outlook.com:** Full support — opacity, absolute positioning, border-radius all work
- **Outlook desktop (Word renderer):** CSS `opacity` is ignored. The pre-faded watermark PNG solves this — the transparency is baked into the image itself, not applied via CSS. Absolute positioning is supported via VML fallback that Outlook generates.
- **No CSS classes:** Everything uses inline styles (email clients strip `<style>` blocks inconsistently)
- **No external fonts:** System font stack only

View File

@@ -0,0 +1,342 @@
# Password Reset & Multi-Factor Authentication Design
**Date**: 2026-04-26
**Status**: Approved
**Scope**: Self-service password reset, TOTP MFA with backup codes, per-tenant MFA enforcement
## Overview
Add two missing auth capabilities to the Cameleer SaaS platform:
1. **Self-service password reset** — "Forgot password?" flow in the custom sign-in UI, using Logto's Experience API and the already-configured `ForgotPassword` email template.
2. **Multi-factor authentication** — TOTP (authenticator app) as primary factor, backup codes for recovery. Per-tenant enforcement (tenant admins control whether MFA is required for their org) plus optional opt-in for any user.
## Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| MFA methods | TOTP + backup codes | Universal, no extra infra. WebAuthn can be added later. |
| MFA policy at Logto level | `UserControlled` | Per-tenant enforcement is application-layer; Logto stays permissive. |
| Enforcement mechanism | JWT claim (`mfa_enrolled`) | Stateless, works for both SaaS and cameleer-server, extends existing custom JWT script. |
| MFA during password reset | Not required | Email verification code is sufficient proof of identity. Requiring TOTP risks deadlock if user lost both. |
| Enrollment location (SaaS) | Tenant portal Settings page | Natural home next to existing password change. |
| Enrollment location (server) | Cameleer-server UI (handoff doc) | Regular org members interact with server UI day-to-day. |
| MFA requirement storage | `settings` JSONB on `TenantEntity` | No migration needed. |
## 1. Password Reset Flow
### UI Changes (`ui/sign-in/src/SignInPage.tsx`)
Add a "Forgot password?" link below the password field on the sign-in form. New mode `forgotPassword` with two sub-steps:
1. **Email entry** — user enters their email, clicks "Send code"
2. **Code + new password** — 6-digit verification code (reuse existing OTP input pattern from registration) + new password + confirm password
The "Forgot password?" link is hidden when no email connector is configured — check the same sign-in experience config already fetched at page load.
### Experience API Flow (`ui/sign-in/src/experience-api.ts`)
```
PUT /api/experience
{ interactionEvent: 'ForgotPassword' }
POST /api/experience/verification/verification-code
{ identifier: { type: 'email', value: email }, interactionEvent: 'ForgotPassword' }
POST /api/experience/verification/verification-code/verify
{ identifier: { type: 'email', value: email }, verificationId, code }
POST /api/experience/identification
{ verificationId }
POST /api/experience/profile
{ type: 'password', value: newPassword }
POST /api/experience/submit
-> redirect to sign-in page
```
### Security Notification Email
After a successful password reset, Logto sends a confirmation redirect — but no security awareness email. The SaaS backend sends a separate **security notification email** to the user's address with:
- Subject: "Your Cameleer password was reset"
- Body: confirms the password was changed, states that **MFA was not required for this change**, and recommends enabling MFA if not already enrolled
- Includes a timestamp and "If this wasn't you, contact your administrator immediately"
This is triggered by the custom sign-in UI after the Experience API `submit` succeeds — a `POST /api/password-reset-notification` call to the SaaS backend with the user's email. The backend sends the email via the configured SMTP connector. The endpoint is unauthenticated (the user hasn't signed in yet) but rate-limited and accepts only email addresses that exist in Logto.
### Backend Changes
Minimal. The password reset flow itself is entirely between the custom sign-in UI and Logto's Experience API. The `ForgotPassword` email template is already configured in `EmailConnectorService`. The only new backend work is the security notification endpoint above.
## 2. MFA Configuration (Logto Bootstrap)
### Bootstrap Changes (`docker/logto-bootstrap.sh`)
Add MFA configuration to the existing Phase 8c sign-in experience patch:
```json
{
"mfa": {
"factors": ["Totp", "BackupCode"],
"policy": "UserControlled"
}
}
```
This is one additional field in the existing `PATCH /api/sign-in-exp` call — not a separate bootstrap phase.
### Custom JWT Script Extension (Phase 7b)
Extend the existing `getCustomJwtClaims` script to include an `mfa_enrolled` claim. Logto's custom JWT context provides `context.user.mfaVerificationFactors` — an array of the user's enrolled MFA factors.
```javascript
const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" };
const roles = new Set();
if (context?.user?.organizationRoles) {
for (const orgRole of context.user.organizationRoles) {
const mapped = roleMap[orgRole.roleName];
if (mapped) roles.add(mapped);
}
}
if (context?.user?.roles) {
for (const role of context.user.roles) {
if (role.name === "saas-vendor") roles.add("server:admin");
}
}
// MFA enrollment status
const mfaFactors = context?.user?.mfaVerificationFactors || [];
const mfaEnrolled = mfaFactors.some(f => f.type === 'Totp');
const claims = {};
if (roles.size > 0) claims.roles = [...roles];
claims.mfa_enrolled = mfaEnrolled;
return claims;
};
```
Every JWT now carries `mfa_enrolled: true/false`, readable by both SaaS backend and cameleer-server.
## 3. MFA Verification at Sign-in
### How Logto Signals MFA Is Needed
After password verification + identification, `POST /api/experience/submit` returns an error with code `mfa_factor_not_satisfied` instead of a redirect URL. The custom sign-in UI intercepts this and prompts for TOTP.
### UI Changes (`ui/sign-in/src/SignInPage.tsx`)
New mode `mfaVerify` — shown when submit returns `mfa_factor_not_satisfied`:
- 6-digit TOTP code input (reuse existing OTP input pattern)
- **Prominent backup code fallback** — not a subtle link. Below the TOTP input, a clearly visible secondary action: "Lost your device? Use a backup code" styled as a distinct button or card (not a footnote-sized link). Users in a panic skip small text. The backup code view shows a single text input with clear instructions: "Enter one of your 10 backup codes"
### Experience API Flow
**TOTP verification:**
```
POST /api/experience/verification/totp/verify
{ code: '123456' }
-> returns { verificationId }
POST /api/experience/identification
{ verificationId }
POST /api/experience/submit
-> redirectTo
```
**Backup code verification:**
```
POST /api/experience/verification/backup-code/verify
{ code: 'abc123def456' }
-> returns { verificationId }
POST /api/experience/identification
{ verificationId }
POST /api/experience/submit
-> redirectTo
```
### Modified Sign-in Flow
The existing `signIn()` function (`init -> verifyPassword -> identify -> submit`) becomes:
1. `init -> verifyPassword -> identify -> submit`
2. If submit returns `mfa_factor_not_satisfied` -> UI shows TOTP input
3. User enters code -> `verify TOTP -> identify -> submit -> redirect`
### Error Handling
| Error | Message |
|-------|---------|
| Invalid TOTP code | "Invalid code, please try again" |
| Backup code already used | "This backup code has already been used" |
| All backup codes exhausted | "No backup codes remaining. Contact your administrator." |
## 4. MFA Enrollment (Tenant Portal)
### Settings Page UI (`ui/src/pages/SettingsPage.tsx`)
Add an "MFA" section to the existing Settings page (next to password change).
**Not enrolled state:**
- Description: "Protect your account with two-factor authentication"
- "Set up authenticator app" button
- Enrollment flow:
1. Backend generates TOTP secret -> returns secret + QR code URI
2. UI renders QR code (lightweight library, e.g., `qrcode.react`)
3. User scans with authenticator app, enters 6-digit verification code to confirm
4. On success -> backend generates 10 backup codes, displays them once
5. User must copy/download before dismissing (checkbox: "I've saved these codes")
6. Force token refresh so `mfa_enrolled` JWT claim updates immediately
**Already enrolled state:**
- "Authenticator app configured" with green status indicator
- "Regenerate backup codes" button (new set of 10, invalidates old)
- "Remove MFA" button with confirmation dialog + password re-entry
### Backend Endpoints (new, in `TenantPortalController`)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/tenant/mfa/status` | Check current user's MFA enrollment status |
| `POST` | `/api/tenant/mfa/totp/setup` | Generate TOTP secret via Logto Management API -> return secret + QR code |
| `POST` | `/api/tenant/mfa/totp/verify` | Verify TOTP code + bind to user via Management API |
| `POST` | `/api/tenant/mfa/backup-codes` | Generate backup codes via Management API |
| `DELETE` | `/api/tenant/mfa/totp` | Remove TOTP factor (requires password confirmation) |
All proxy to Logto's Management API via `LogtoManagementClient`:
- `POST /api/users/{userId}/mfa-verifications` — create TOTP or backup codes
- `GET /api/users/{userId}/mfa-verifications` — list enrolled factors
- `DELETE /api/users/{userId}/mfa-verifications/{verificationId}` — remove a factor
### Team Management (`ui/src/pages/TeamPage.tsx`)
Add a "Reset MFA" action for team members — allows tenant owners/operators to remove MFA enrollment for a locked-out user. Calls `DELETE /api/users/{userId}/mfa-verifications/{verificationId}` via a new backend endpoint:
| Method | Path | Purpose |
|--------|------|---------|
| `DELETE` | `/api/tenant/users/{userId}/mfa` | Remove all MFA factors for a team member |
## 5. Per-Tenant MFA Enforcement
### Tenant Setting
Stored in the existing `settings` JSONB column on `TenantEntity`:
```json
{ "mfaRequired": true }
```
Default is absent/`false` — MFA is optional, users can still opt in.
### Tenant Admin Toggle (`ui/src/pages/SettingsPage.tsx`)
Visible only to tenant admins (owner/operator role):
- Toggle: "Require MFA for all organization members"
- Enabling shows confirmation: "Members without MFA will be prompted to enroll on their next sign-in. Are you sure?"
- Calls `PATCH /api/tenant/settings` with `{ "mfaRequired": true/false }`
### Backend Enforcement
A Spring Security filter that runs after JWT validation:
1. Extract `mfa_enrolled` claim from JWT
2. Look up the user's tenant -> check `settings.mfaRequired`
3. If tenant requires MFA and `mfa_enrolled` is `false`:
- Return `403` with a **distinct application error code** to avoid collision with generic Spring Security 403s (which get caught by global error handlers):
```json
{
"error": "APP_MFA_REQUIRED",
"code": "mfa_enrollment_required",
"message": "Your organization requires multi-factor authentication"
}
```
- The response includes a custom header `X-Cameleer-Error: APP_MFA_REQUIRED` as a belt-and-suspenders signal — frontends can check either the body or the header
- **Exempt paths**: `/api/tenant/mfa/*` (enrollment endpoints), `/api/config`, `/api/me`
4. Frontend intercepts 403 responses and checks for `APP_MFA_REQUIRED` specifically (not all 403s) -> redirects to Settings page with MFA section highlighted and an inline banner explaining the requirement
### Cameleer-Server Enforcement (via handoff doc)
- Read `mfa_enrolled` from JWT (same token, same parsing as `roles` claim)
- Query tenant MFA policy: SaaS exposes `GET /api/tenant/{slug}/mfa-policy` returning `{ "mfaRequired": true/false }` — server caches with 5-minute TTL
- Same enforcement logic: if required and not enrolled -> 403 with `APP_MFA_REQUIRED` error code (same format as SaaS backend)
### Token Refresh After Enrollment
When a user completes MFA enrollment in the tenant portal, the frontend calls `getAccessToken()` from the Logto SDK with a forced refresh. This gets a new JWT with `mfa_enrolled: true`, and subsequent requests pass enforcement.
### Edge Case: Admin Enables Enforcement While Users Are Active
Existing sessions have `mfa_enrolled: false`. On next API call, backend returns 403. Frontend redirects to enrollment. After enrolling + token refresh, they're unblocked. No forced logout needed.
## 6. Backup Codes
### Generation
10 codes generated by Logto's Management API (`POST /api/users/{userId}/mfa-verifications` with `{ type: "BackupCode" }`). Logto generates the codes.
### Display
Shown exactly once, immediately after TOTP enrollment succeeds:
- Grid of 10 codes in monospace font
- "Copy all" button
- "Download as .txt" button
- Dialog cannot be dismissed until user checks "I've saved my backup codes"
### Regeneration
Available in Settings page for enrolled users. "Regenerate backup codes" creates a new set of 10, invalidates all previous codes. Same display/acknowledgment flow.
### Low-Code Warning
When a user signs in with a backup code and fewer than 3 remain, the sign-in UI shows a warning after successful auth: "You have N backup codes remaining."
### Exhaustion Recovery
If all backup codes are used and the user can't access their authenticator app, a tenant admin removes their MFA via the "Reset MFA" action on the Team page (`DELETE /api/tenant/users/{userId}/mfa`).
## 7. Server Handoff Document
A separate spec file to be delivered alongside this design, covering:
1. **API contract** — Logto Management API endpoints for MFA enrollment/unenrollment (exact URLs, request/response payloads, M2M token scope requirements)
2. **UX requirements** — QR code display, backup code download, verification step, enrollment/removal flows
3. **Enforcement model** — reading `mfa_enrolled` from JWT, querying `GET /api/tenant/{slug}/mfa-policy` for tenant requirement, 403 response format
4. **Error states** — already enrolled, invalid TOTP, backup code exhaustion, removal while enforcement is active
## Summary of Changes by Component
| Component | Changes |
|-----------|---------|
| `ui/sign-in/src/SignInPage.tsx` | New modes: `forgotPassword`, `mfaVerify` (with prominent backup code fallback) |
| `ui/sign-in/src/experience-api.ts` | New functions: `forgotPassword()`, `verifyTotp()`, `verifyBackupCode()`, `notifyPasswordReset()` |
| `ui/src/pages/SettingsPage.tsx` | MFA enrollment section, MFA requirement toggle, `APP_MFA_REQUIRED` 403 interceptor |
| `ui/src/pages/TeamPage.tsx` | "Reset MFA" action for team members |
| `docker/logto-bootstrap.sh` | MFA factors in Phase 8c, `mfa_enrolled` claim in Phase 7b |
| `TenantPortalController.java` | MFA endpoints (setup, verify, backup codes, status, remove) |
| `TenantPortalService.java` | MFA business logic proxying to `LogtoManagementClient` |
| `LogtoManagementClient.java` | New methods for MFA Management API calls |
| `SecurityConfig.java` or new filter | MFA enforcement filter (`APP_MFA_REQUIRED` error code + `X-Cameleer-Error` header) |
| New: password reset notification | `POST /api/password-reset-notification` — security email after reset (unauthenticated, rate-limited) |
| New: server handoff doc | MFA API contract + UX + enforcement spec for cameleer-server team |
## Out of Scope
- WebAuthn / passkey support (future addition)
- SMS-based MFA
- Password policies beyond Logto defaults (complexity, history, expiry)
- Passwordless authentication
- Social login / OAuth providers
- Account lockout configuration

View File

@@ -0,0 +1,152 @@
# Cameleer-Server MFA Handoff Document
**Date:** 2026-04-26
**For:** cameleer-server team
**Context:** The SaaS platform now supports TOTP MFA with backup codes. This document specifies what the server team needs to implement for MFA enrollment in the server UI.
## 1. JWT Claim: `mfa_enrolled`
Every access token now includes an `mfa_enrolled: boolean` claim, set by the Logto Custom JWT script. The server already parses JWT claims for the `roles` field — `mfa_enrolled` works identically.
**Example decoded JWT payload:**
```json
{
"sub": "user-id-123",
"roles": ["server:admin"],
"mfa_enrolled": true,
"aud": "https://api.cameleer.local",
"scope": "tenant:manage tenant:view"
}
```
## 2. Enforcement
### When to enforce
Check whether the tenant requires MFA:
- **Endpoint:** `GET /platform/api/tenant/{slug}/mfa-policy`
- **Auth:** M2M token (same as existing server -> SaaS API calls)
- **Response:** `{ "mfaRequired": true/false }`
- **Cache:** 5-minute TTL recommended
### How to enforce
On authenticated requests, if `mfaRequired` is `true` and the JWT `mfa_enrolled` claim is `false`:
**Response:**
```
HTTP 403
X-Cameleer-Error: APP_MFA_REQUIRED
Content-Type: application/json
{
"error": "APP_MFA_REQUIRED",
"code": "mfa_enrollment_required",
"message": "Your organization requires multi-factor authentication"
}
```
**Exempt paths:** MFA enrollment endpoints (below), health checks, public assets.
The server UI should intercept 403 responses with `X-Cameleer-Error: APP_MFA_REQUIRED` and redirect to the MFA enrollment page.
## 3. MFA Enrollment API
The server needs to call Logto's Management API to manage MFA for users. Use the existing M2M token for authentication.
### Get MFA status
```
GET https://{logto-endpoint}/api/users/{userId}/mfa-verifications
Authorization: Bearer {m2m_token}
Response: [
{ "id": "ver-123", "type": "Totp", "createdAt": "..." },
{ "id": "ver-456", "type": "BackupCode", "createdAt": "..." }
]
```
### Generate TOTP secret
Generate a 20-byte random secret, Base32-encode it, and create a QR code URI:
```
otpauth://totp/Cameleer:{userEmail}?secret={base32Secret}&issuer=Cameleer
```
Show the QR code to the user. After they scan and provide a 6-digit code, verify it server-side using the TOTP algorithm (RFC 6238, HMAC-SHA1, 30-second window, +/-1 step drift).
### Bind TOTP to user
After successful verification:
```
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
Authorization: Bearer {m2m_token}
Content-Type: application/json
{ "type": "Totp", "secret": "{base32Secret}" }
Response: { "type": "Totp", "secret": "...", "secretQrCode": "..." }
```
### Generate backup codes
After TOTP is bound:
```
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
Authorization: Bearer {m2m_token}
Content-Type: application/json
{ "type": "BackupCode" }
Response: { "type": "BackupCode", "codes": ["abc123", "def456", ...] }
```
Display the 10 codes once. User must acknowledge saving them before dismissing.
### Remove MFA (admin action)
```
DELETE https://{logto-endpoint}/api/users/{userId}/mfa-verifications/{verificationId}
Authorization: Bearer {m2m_token}
```
Remove all verifications (TOTP + BackupCode) to fully reset MFA for a user.
## 4. UX Requirements
### Enrollment flow
1. User clicks "Set up MFA" in settings
2. Show QR code (200x200px) with the TOTP secret URI
3. User scans with authenticator app
4. User enters 6-digit verification code
5. On success -> show 10 backup codes in a 2-column monospace grid
6. "Copy all" and "Download .txt" buttons
7. Checkbox: "I've saved my backup codes" — must be checked before dismissing
8. After dismissal, force token refresh to get `mfa_enrolled: true` in JWT
### Enrolled state
- Show "Authenticator app configured" with green status badge
- "Regenerate backup codes" button
- "Remove MFA" button with confirmation dialog
### Backup code fallback (sign-in)
This is handled by the SaaS custom sign-in UI, not the server. No server changes needed for the sign-in flow.
## 5. Error States
| Scenario | Response |
|----------|----------|
| User already has TOTP enrolled | 422 — "TOTP already configured" |
| Invalid TOTP code during setup | Show error, let user retry |
| Backup code already used (sign-in) | Handled by SaaS sign-in UI |
| All backup codes exhausted | Admin removes MFA via team page |
| Remove MFA while enforcement active | User will be prompted to re-enroll on next request |

View File

@@ -0,0 +1,381 @@
# Passkey MFA Design
**Date:** 2026-04-27
**Status:** Implemented
**Goal:** Add passkeys (WebAuthn) as an MFA factor alongside existing TOTP, with a long-term path toward passwordless sign-in.
## Motivation
Passkeys provide a phishing-resistant, convenient alternative to TOTP codes. Short-term, they serve as an additional MFA option (fingerprint/Face ID instead of typing a 6-digit code). Long-term, they enable passwordless sign-in once adoption is sufficient.
## Approach
**Logto-native WebAuthn (Approach A).** All WebAuthn ceremony handling, credential storage, and factor management stays in Logto via the Experience API and Management API. The SaaS backend adds hierarchical policy enforcement and exposes Logto's credential data through its own API. No custom WebAuthn libraries, no credential mirroring — we work within what Logto provides.
## 1. Policy Model
### Two Independent Policy Domains
Vendor and tenant policies are **independent scopes**, not a hierarchy. No inheritance, no floor.
| Policy | Scope | Who it affects | Who sets it |
|--------|-------|---------------|-------------|
| **Vendor auth policy** | SaaS platform logins (`/platform/*`) | Tenant admins accessing the management plane | Platform admin (vendor) |
| **Tenant auth policy** | Tenant server/dashboard logins | Org users accessing the tenant's cameleer-server | Tenant admin |
**Example scenario:** Vendor requires passkey for tenant admin platform logins. A tenant admin sets `mfa_mode: off` for their org. Result: that tenant admin must use a passkey to access the SaaS platform, but their end users sign into the cameleer dashboard with just username/password.
### Policy Settings
Each policy domain has three settings:
| Setting | Values | Default | Meaning |
|---------|--------|---------|---------|
| `mfa_mode` | `off`, `optional`, `required` | `off` | Whether MFA is required for sign-in |
| `passkey_enabled` | `true`, `false` | `false` | Whether passkeys are available as a factor |
| `passkey_mode` | `optional`, `preferred`, `required` | `optional` | Passkey enforcement level (only relevant when `passkey_enabled: true`) |
- `passkey_mode: optional` — User can choose passkey or TOTP
- `passkey_mode: preferred` — Passkey prompted first, TOTP available as fallback
- `passkey_mode: required` — Passkey is the only accepted MFA factor
### Storage
- **Vendor policy:** `vendor_auth_policy` single-row table with a management endpoint for runtime updates. Changes survive restarts without redeployment.
- **Tenant policy:** Existing `settings` JSONB column on `tenants` table, extending the current `mfaRequired` key. New keys: `mfaMode`, `passkeyEnabled`, `passkeyMode`. The existing `mfaRequired: true` maps to `mfaMode: "required"` (backward-compatible).
### Enforcement
- **`MfaEnforcementFilter`** expands to cover two route groups:
- `/api/vendor/**`, `/api/portal/**` — Checked against vendor auth policy
- `/api/tenant/**` — Checked against tenant auth policy (from tenant `settings`)
- Filter reads JWT claims `mfa_enrolled` and `passkey_enrolled` to determine user's factor status
- Error codes returned:
- `MFA_REQUIRED` — User must enroll in some MFA factor
- `PASSKEY_REQUIRED` — Passkey specifically required by policy
- Frontend uses these error codes to route users to the correct enrollment flow
## 2. Passkey Flows
### 2.1 Registration (Three Entry Points)
All three entry points use the same underlying WebAuthn registration ceremony via Logto Experience API.
**Settings page (deliberate enrollment):**
1. User navigates to MFA settings in platform UI
2. Clicks "Add passkey"
3. Frontend calls SaaS backend `POST /api/tenant/mfa/webauthn/register/start`
4. Backend calls Logto Management API to create a WebAuthn verification for the user, returns WebAuthn registration options (challenge, RP info, user info)
5. Browser executes `navigator.credentials.create()` with the options via `@simplewebauthn/browser`
6. Frontend sends the credential attestation to `POST /api/tenant/mfa/webauthn/register/complete`
7. Backend forwards attestation to Logto Management API to complete registration, passkey appears in device list
**Post-sign-in nudge (organic adoption):**
1. User signs in with password + TOTP (or password only if MFA optional)
2. If passkey is enabled for the policy domain and user has no passkeys enrolled, show a dismissible banner: "Sign in faster with a passkey"
3. User clicks "Set up" → same registration ceremony as settings page
4. User clicks "Not now" → dismiss for 30 days (stored in `localStorage`)
**Onboarding wizard (new users):**
1. New step after account creation, before tenant provisioning: "Secure your account with a passkey"
2. Same registration ceremony
3. "Skip" button available — passkey is not forced during onboarding regardless of policy (user hasn't joined an org yet, so no policy applies)
### 2.2 Authentication (Sign-In Flow)
1. User enters email + password → Logto validates credentials
2. If MFA required (per effective policy), check enrolled factors:
- **Passkey + TOTP enrolled:** Show last-used method by default. "Use [other method] instead" link below.
- **Passkey only:** WebAuthn assertion prompt
- **TOTP only:** Existing TOTP code input (unchanged)
- **Neither enrolled, MFA required:** Redirect to enrollment flow
3. Smart default: read `mfa_method_preference` from JWT custom data claim. Updated on each successful verification. Sign-in UI reads this to decide which prompt to show first.
4. On successful factor verification → Logto completes session, issues token with `mfa_enrolled: true` and `passkey_enrolled: true`
### 2.3 WebAuthn Ceremony Details
**Registration via settings page (Management API — user is already signed in):**
```
1. Frontend → SaaS backend: POST /api/tenant/mfa/webauthn/register/start
2. SaaS backend → Logto Management API: POST /api/users/{userId}/mfa-verifications
(type: "WebAuthn") → returns registration options (challenge, RP, user info)
3. SaaS backend → Frontend: registration options
4. Browser: navigator.credentials.create(options) → attestation response
5. Frontend → SaaS backend: POST /api/tenant/mfa/webauthn/register/complete
6. SaaS backend → Logto Management API: complete verification with attestation
```
**Registration during sign-in (Experience API — user is mid-authentication):**
```
1. POST /api/experience/verification/web-authn/registration → get creation options
2. Browser: navigator.credentials.create(options)
3. POST /api/experience/verification/web-authn/registration/verify → send attestation
4. POST /api/experience/identification → identify user
5. POST /api/experience/submit → complete
```
**Assertion during sign-in (Experience API — MFA step after password):**
```
1. POST /api/experience/verification/web-authn/authentication → get request options
2. Browser: navigator.credentials.get(options)
3. POST /api/experience/verification/web-authn/authentication/verify → send assertion
Returns: verificationId
4. POST /api/experience/identification → identify user
5. POST /api/experience/submit → complete, returns redirectTo
```
### 2.4 Device Management (Settings UI)
Users can manage their registered passkeys from the MFA settings page:
- **List:** Show all registered WebAuthn credentials — name (user-settable), user-agent from registration, creation date
- **Rename:** Update credential name via Logto Management API
- **Delete:** Remove credential via Management API. If it's the last passkey and passkey is required, block deletion.
**Logto metadata available per credential:**
- `id` — credential identifier
- `type``"WebAuthn"`
- `name` — user-settable display name (nullable)
- `agent` — user-agent string captured at registration time
- `createdAt` — timestamp
**Not available from Logto:** `lastUsedAt` — Logto does not track last-used date. The UI will not show this field.
## 3. Backend Changes
### 3.1 Database Migration
**New table: `vendor_auth_policy`** (single-row config)
| Column | Type | Default | Purpose |
|--------|------|---------|---------|
| `id` | INTEGER | 1 | Single-row constraint |
| `mfa_mode` | VARCHAR(10) | `'off'` | `off`, `optional`, `required` |
| `passkey_enabled` | BOOLEAN | `false` | Whether passkeys available |
| `passkey_mode` | VARCHAR(10) | `'optional'` | `optional`, `preferred`, `required` |
| `updated_at` | TIMESTAMP | now() | Last update |
**Tenant settings JSONB extension:** Add new keys to the whitelist in `TenantPortalService.updateTenantSettings()`:
| Key | Type | Default | Purpose |
|-----|------|---------|---------|
| `mfaMode` | string | `"off"` | Replaces/supersedes `mfaRequired` |
| `passkeyEnabled` | boolean | `false` | Whether passkeys available for tenant users |
| `passkeyMode` | string | `"optional"` | Passkey enforcement level |
**Backward compatibility:** `mfaRequired: true` treated as `mfaMode: "required"`. The filter checks both: if `mfaMode` is present, use it; otherwise fall back to `mfaRequired`.
### 3.2 MfaEnforcementFilter Changes
Current behavior: only intercepts `/api/tenant/**`, checks `mfa_enrolled` claim against tenant `settings.mfaRequired`.
New behavior:
- **Route matching:** Intercept `/api/vendor/**` and `/api/portal/**` in addition to `/api/tenant/**`
- **Policy lookup:**
- Vendor/portal routes → read `vendor_auth_policy` table
- Tenant routes → read tenant `settings` (existing behavior, extended)
- **Claim checks:**
- `mfa_mode: required` → require `mfa_enrolled == true`
- `passkey_mode: required` → require `passkey_enrolled == true`
- `passkey_mode: preferred` → same as optional for enforcement (preference handled in sign-in UI)
- **Error codes:**
- `APP_MFA_REQUIRED` (existing) — MFA enrollment needed
- `APP_PASSKEY_REQUIRED` (new) — Passkey specifically required
- **Exempt routes:** Add `/api/vendor/auth-policy` and `/api/portal/auth-settings` to exempt list so admins can read/update policy without triggering enforcement
### 3.3 LogtoManagementClient Additions
New methods for WebAuthn credential management:
| Method | Logto Endpoint | Purpose |
|--------|---------------|---------|
| `listWebAuthnCredentials(userId)` | `GET /api/users/{userId}/mfa-verifications` | List credentials, filter by `type: "WebAuthn"` |
| `deleteWebAuthnCredential(userId, verificationId)` | `DELETE /api/users/{userId}/mfa-verifications/{verificationId}` | Remove a passkey |
| `renameWebAuthnCredential(userId, verificationId, name)` | `PATCH /api/users/{userId}/mfa-verifications/{verificationId}` | Update display name |
| `updateUserCustomData(userId, data)` | `PATCH /api/users/{userId}/custom-data` | Set `mfa_method_preference` |
### 3.4 Custom JWT Script Update
Extend the existing `getCustomJwtClaims` function in `docker/logto-bootstrap.sh`:
```javascript
const factors = context.user?.mfaVerificationFactors ?? [];
return {
roles: /* ... existing role mapping ... */,
mfa_enrolled: factors.includes('Totp') || factors.includes('WebAuthn'),
passkey_enrolled: factors.includes('WebAuthn'),
mfa_method_preference: context.user?.customData?.mfa_method_preference ?? null,
};
```
**Changes from current script:**
- `mfa_enrolled` now returns `true` for either TOTP or WebAuthn (was TOTP-only)
- New `passkey_enrolled` boolean claim
- New `mfa_method_preference` claim from user custom data
### 3.5 API Endpoints
**Vendor auth policy (platform:admin only):**
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/vendor/auth-policy` | Read current vendor auth policy |
| `PUT` | `/api/vendor/auth-policy` | Update vendor auth policy |
**Tenant auth settings (tenant:manage scope):**
Extend existing `TenantPortalController`:
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/tenant/auth-settings` | Read tenant auth policy |
| `PUT` | `/api/tenant/auth-settings` | Update tenant auth policy |
**Passkey management (authenticated users):**
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/tenant/mfa/webauthn` | List user's passkey credentials |
| `POST` | `/api/tenant/mfa/webauthn/register/start` | Initiate WebAuthn registration |
| `POST` | `/api/tenant/mfa/webauthn/register/complete` | Complete WebAuthn registration |
| `PATCH` | `/api/tenant/mfa/webauthn/{id}/name` | Rename a passkey |
| `DELETE` | `/api/tenant/mfa/webauthn/{id}` | Delete a passkey |
**Public config extension:**
`GET /api/config` response adds:
```json
{
"vendorAuthPolicy": { "mfaMode": "...", "passkeyEnabled": true, "passkeyMode": "..." }
}
```
`GET /{slug}/mfa-policy` response extends to:
```json
{
"mfaMode": "required",
"passkeyEnabled": true,
"passkeyMode": "preferred"
}
```
## 4. Sign-In UI Changes
### 4.1 New Modes
Add to the `Mode` type in `SignInPage.tsx`:
| Mode | When shown | UI |
|------|------------|-----|
| `mfaWebauthn` | Passkey verification during sign-in | "Verifying your identity..." with browser passkey prompt |
| `mfaMethodPicker` | User has both TOTP and passkey, choosing which to use | Two buttons: "Use passkey" / "Use authenticator code" |
### 4.2 Experience API Client Additions
New functions in `experience-api.ts`:
```typescript
// Initiate WebAuthn authentication, returns challenge options
async function startWebAuthnAuth(): Promise<WebAuthnOptions>
// Verify WebAuthn assertion response, returns verificationId
async function verifyWebAuthnAuth(credential: PublicKeyCredential): Promise<string>
// Initiate WebAuthn registration, returns creation options
async function startWebAuthnRegistration(): Promise<WebAuthnCreationOptions>
// Verify WebAuthn registration attestation
async function verifyWebAuthnRegistration(credential: PublicKeyCredential): Promise<string>
```
### 4.3 Sign-In Flow Changes
After password verification, when Logto returns an MFA challenge:
1. Read effective auth policy from `/api/config` or `/{slug}/mfa-policy`
2. Read `mfa_method_preference` from the MFA challenge context (or default to passkey if `passkey_mode: preferred`)
3. Route to:
- `mfaWebauthn` mode if preference is passkey
- `mfaVerify` mode (existing) if preference is TOTP
- `mfaMethodPicker` if no preference set and both are enrolled
4. Each mode shows a link to switch to the other method
### 4.4 Post-Sign-In Nudge
After successful sign-in, if:
- Passkey is enabled in the effective policy
- User has no passkeys enrolled
- Nudge hasn't been dismissed in the last 30 days
Show a banner at the top of the platform UI (not in the sign-in UI — this happens after redirect back to the SPA).
### 4.5 WebAuthn Browser API
Use `@simplewebauthn/browser` for the client-side WebAuthn ceremony:
- `startRegistration(options)` — wraps `navigator.credentials.create()`
- `startAuthentication(options)` — wraps `navigator.credentials.get()`
This handles Base64URL encoding, browser compatibility, and platform authenticator detection.
## 5. Platform UI Changes
### 5.1 MFA Settings Page Extension
Add a "Passkeys" section below the existing TOTP section in `SettingsPage.tsx`:
**When no passkeys enrolled:**
- "Add a passkey" button
- Brief explanation: "Use your fingerprint, face, or security key to sign in"
**When passkeys exist:**
- Table/list of registered passkeys showing:
- Name (editable inline or via edit button)
- Device info (parsed from user-agent string — "Chrome on Windows", "Safari on iPhone", etc.)
- Created date
- Delete button (with confirmation dialog)
- "Add another passkey" button
### 5.2 Auth Policy Management
**Vendor settings (new page or section):**
- Under vendor admin area, "Authentication Policy" section
- Three controls matching the policy settings (mfa_mode dropdown, passkey_enabled toggle, passkey_mode dropdown)
- Passkey_mode control disabled when passkey_enabled is false
**Tenant settings (extend existing):**
- Current MFA toggle (`mfaRequired`) replaced with richer controls
- Same three controls as vendor, scoped to tenant users
- Backward-compatible: existing `mfaRequired: true` shown as `mfaMode: required`
### 5.3 Onboarding Wizard
Add optional passkey registration step to `OnboardingPage.tsx`:
- After org creation, before redirect
- "Secure your account with a passkey" with setup and skip buttons
- Only shown if passkeys are enabled in vendor policy (read from `/api/config`)
## 6. Passwordless Future Path
This design enables passwordless sign-in without additional architecture changes:
1. **`passkey_mode: required`** already means passkey is the only accepted factor
2. When Logto supports passkey-as-primary-factor (passwordless sign-in), the sign-in UI adds a "Sign in with passkey" button on the initial screen (before email/password)
3. The policy model already supports this: a future `auth_mode: passwordless` setting could skip the password step entirely
4. No backend changes needed — the JWT claims and enforcement filter already handle passkey-only scenarios
This is not part of the current implementation scope but validates that the design doesn't paint us into a corner.
## 7. Out of Scope
- Passwordless sign-in (passkey as primary factor, no password) — future work
- Conditional UI / autofill-assisted passkey discovery — depends on Logto Experience API support
- Cross-device passkey sync management — handled by OS/browser, not our concern
- FIDO2 attestation policy (which authenticators to trust) — Logto defaults are sufficient
- Rate limiting on WebAuthn ceremonies — handled by Logto
## 8. Dependencies
- Logto v1.11.0+ (WebAuthn MFA support) — current `ghcr.io/logto-io/logto:latest` satisfies this
- `@simplewebauthn/browser` npm package for sign-in UI and platform UI
- No Java WebAuthn libraries needed (all ceremonies handled by Logto)

View File

@@ -0,0 +1,299 @@
# Vendor Admin Management & Account Settings
**Date:** 2026-04-27
**Status:** Approved
## Problem
1. The vendor console supports only a single platform admin (created during bootstrap via `SAAS_ADMIN_USER`). There is no way to add additional administrators.
2. There is no page where any authenticated user (vendor or tenant) can manage their own account — display name, password, MFA enrollment.
## Design Decisions
- **Flat admin model** — all vendor admins are equal (`platform:admin`), no tiers
- **Invite or create** — invite via email when connector is configured, create with temporary credentials when it's not
- **Shared account settings** — single `/settings/account` route for any authenticated user (vendor or tenant), reached via user menu in header
- **Password change requires current password** — verified via ROPC token exchange against Logto
- **Confirmation email** on any successful password change (uses existing `PasswordResetNotificationService`)
- **Forgot password link** on sign-in page (flow already implemented, just needs visible link)
- **MFA self-service** — TOTP setup/removal, backup codes, passkey list/rename/delete. Passkey registration remains in sign-in flow (Logto Experience API).
---
## Section 1: Backend — New `account/` Package
### AccountService
New service at `src/main/java/net/siegeln/cameleer/saas/account/AccountService.java`.
Extracts user-level identity operations from `TenantPortalService`:
| Method | Extracted from | Logto API |
|--------|---------------|-----------|
| `getProfile(userId)` | New | `GET /api/users/{userId}` |
| `updateDisplayName(userId, name)` | `OnboardingService` | `PATCH /api/users/{userId}` |
| `changePassword(userId, currentPassword, newPassword)` | `TenantPortalService.changePassword()` | ROPC verify + `PATCH /api/users/{userId}/password` |
| `validatePassword(password)` | Duplicated in 3+ places | Local validation (min 8 chars) |
| `getMfaStatus(userId)` | `TenantPortalService.getMfaStatus()` | `GET /api/users/{userId}/mfa-verifications` |
| `setupTotp(userId)` | `TenantPortalService.setupTotp()` | `POST /api/users/{userId}/mfa-verifications` |
| `verifyTotpCode(secret, code)` | `TenantPortalService.verifyTotpCode()` | Local HMAC-SHA1 computation |
| `generateBackupCodes(userId)` | `TenantPortalService.generateBackupCodes()` | `POST /api/users/{userId}/mfa-verifications` |
| `removeMfa(userId)` | `TenantPortalService.removeTotp()` | Batch `DELETE /api/users/{userId}/mfa-verifications/{id}` |
| `listPasskeys(userId)` | `TenantPortalService.listPasskeys()` | `GET /api/users/{userId}/mfa-verifications` (filtered) |
| `renamePasskey(userId, id, name)` | `TenantPortalService.renamePasskey()` | `PATCH /api/users/{userId}/mfa-verifications/{id}` |
| `deletePasskey(userId, id)` | `TenantPortalService.deletePasskey()` | `DELETE /api/users/{userId}/mfa-verifications/{id}` |
| `setMfaMethodPreference(userId, pref)` | `TenantPortalService.updateMfaMethodPreference()` | `PATCH /api/users/{userId}/custom-data` |
TOTP helper methods (`computeTotp`, base32 encoding, HMAC-SHA1) move with the service.
### AccountController
New controller at `src/main/java/net/siegeln/cameleer/saas/account/AccountController.java`.
All endpoints require `authenticated()` (any logged-in user, no scope check). User ID extracted from JWT `sub` claim.
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `GET /api/account/profile` | GET | Own display name + email |
| `PATCH /api/account/profile` | PATCH | Update display name |
| `POST /api/account/password` | POST | Change password (current + new) |
| `GET /api/account/mfa/status` | GET | MFA enrollment status |
| `POST /api/account/mfa/totp/setup` | POST | Start TOTP enrollment |
| `POST /api/account/mfa/totp/verify` | POST | Verify TOTP code |
| `POST /api/account/mfa/backup-codes` | POST | Generate backup codes |
| `DELETE /api/account/mfa/totp` | DELETE | Remove TOTP |
| `GET /api/account/mfa/webauthn` | GET | List passkeys |
| `PATCH /api/account/mfa/webauthn/{id}/name` | PATCH | Rename passkey |
| `DELETE /api/account/mfa/webauthn/{id}` | DELETE | Delete passkey |
| `POST /api/account/mfa/method-preference` | POST | Set MFA preference |
### SecurityConfig Changes
Add to the security filter chain:
- `/api/account/**``authenticated()` (any logged-in user)
- `/api/account/mfa/**` exempt from `MfaEnforcementFilter` (same as current `/api/tenant/mfa/` exemption)
### TenantPortalService Consolidation
After extraction, `TenantPortalService` delegates to `AccountService` for user-level operations:
- `changePassword()``accountService.changePassword()`
- `getMfaStatus()``accountService.getMfaStatus()`
- `setupTotp()``accountService.setupTotp()`
- `verifyTotpCode()``accountService.verifyTotpCode()`
- `generateBackupCodes()``accountService.generateBackupCodes()`
- `removeTotp()``accountService.removeMfa()`
- `listPasskeys()``accountService.listPasskeys()`
- `renamePasskey()``accountService.renamePasskey()`
- `deletePasskey()``accountService.deletePasskey()`
- `updateMfaMethodPreference()``accountService.setMfaMethodPreference()`
Tenant-admin operations stay in `TenantPortalService`: `resetTeamMemberMfa`, `resetTeamMemberPassword`, `updateTenantSettings`, `getAuthSettings`, `resetServerAdminPassword`.
Old `/api/tenant/mfa/*` and `/api/tenant/password` endpoints remain as thin delegates to preserve backward compatibility during migration, then deprecated.
### Password Change Flow
1. Client sends `POST /api/account/password` with `{ currentPassword, newPassword }`
2. `AccountService.changePassword()`:
a. `validatePassword(newPassword)` — min 8 chars
b. Fetch user email via `logtoClient.getUser(userId)`
c. Attempt ROPC token exchange: `POST /oidc/token` with `grant_type=password`, user's email + `currentPassword` against the SaaS OIDC app
d. If token exchange fails → 400 "Current password is incorrect"
e. `logtoClient.updateUserPassword(userId, newPassword)`
f. Fire `passwordResetNotificationService.sendNotification(email)` asynchronously
### LogtoManagementClient — ROPC Addition
New method: `verifyPasswordViaRopc(String email, String password)` — attempts password grant against Logto's token endpoint using the SaaS app's client ID. Returns boolean (success/failure). Does not store the returned token.
**Prerequisite:** The SaaS OIDC application in Logto must have the Resource Owner Password Credentials (ROPC) grant type enabled. This is configured in `logto-bootstrap.sh` when creating the application (add `password` to the `grantTypes` array if not already present).
---
## Section 2: Vendor Admin Management
### VendorAdminService
New service at `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java`.
| Method | Purpose | Logto API |
|--------|---------|-----------|
| `listAdmins()` | List all users with `saas-vendor` role | `GET /api/roles/{roleId}/users` |
| `createAdmin(email, tempPassword?)` | Create + assign role (invite or credentials) | `POST /api/users` + `POST /api/users/{id}/roles` |
| `removeAdmin(userId, requesterId)` | Revoke role (blocks self-removal) | `DELETE /api/users/{id}/roles/{roleId}` |
| `resetAdminPassword(userId, newPassword)` | Reset password + send notification | `PATCH /api/users/{id}/password` |
| `resetAdminMfa(userId)` | Delete all MFA verifications | Batch `DELETE /api/users/{id}/mfa-verifications/{vid}` |
**Create admin logic:**
- Check `emailConnectorService.isEmailConnectorConfigured()`
- If configured and no temp password provided: `logtoClient.createAndInviteUser(email)` + assign `saas-vendor` role → returns `{ invited: true }`
- If not configured or temp password provided: `logtoClient.createUserWithPassword(email, tempPassword)` + assign `saas-vendor` role → returns `{ invited: false, tempPassword }`
**Self-removal prevention:** `removeAdmin` compares `userId` with `requesterId` (from JWT `sub`). Throws 400 if equal.
### VendorAdminController
New controller at `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java`. All endpoints require `platform:admin`.
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `GET /api/vendor/admins` | GET | List all vendor admins |
| `POST /api/vendor/admins` | POST | Create/invite new admin |
| `DELETE /api/vendor/admins/{userId}` | DELETE | Remove admin |
| `POST /api/vendor/admins/{userId}/reset-password` | POST | Reset another admin's password |
| `DELETE /api/vendor/admins/{userId}/mfa` | DELETE | Reset another admin's MFA |
### LogtoManagementClient Additions
| Method | Logto API |
|--------|-----------|
| `listRoleUsers(roleId)` | `GET /api/roles/{roleId}/users` |
| `assignGlobalRole(userId, roleId)` | `POST /api/users/{userId}/roles` |
| `revokeGlobalRole(userId, roleId)` | `DELETE /api/users/{userId}/roles/{roleId}` |
| `getRoleByName(roleName)` | `GET /api/roles?search={name}` |
---
## Section 3: Frontend — Shared Account Settings Page
### New Route
`/settings/account` — standalone page with app header and back navigation. Accessible to any authenticated user.
### Extracted Components
Components extracted from `SettingsPage.tsx` into `ui/src/components/account/`:
| Component | Source | File |
|-----------|--------|------|
| `ProfileSection` | New | `ProfileSection.tsx` |
| `PasswordChangeSection` | `SettingsPage.tsx` lines 631-664 | `PasswordChangeSection.tsx` |
| `MfaSection` | `SettingsPage.tsx` lines 34-270 | `MfaSection.tsx` |
| `PasskeySection` | `SettingsPage.tsx` lines 368-462 | `PasskeySection.tsx` |
The `PasswordChangeSection` gains a "current password" field (the existing tenant version only has new password + confirm).
### New Account Settings Page
`ui/src/pages/AccountSettingsPage.tsx` — composes all four sections in order: Profile, Password, MFA, Passkeys.
### Tenant SettingsPage Consolidation
`SettingsPage.tsx` imports the shared components from `ui/src/components/account/` instead of defining them inline. Keeps its tenant-specific sections: `AuthPolicySection`, `MfaEnforcementToggle`, server admin password reset.
### New API Hooks
`ui/src/hooks/account-hooks.ts`:
| Hook | Endpoint |
|------|----------|
| `useAccountProfile()` | `GET /api/account/profile` |
| `useUpdateDisplayName()` | `PATCH /api/account/profile` |
| `useChangePassword()` | `POST /api/account/password` |
| `useAccountMfaStatus()` | `GET /api/account/mfa/status` |
| `useAccountMfaSetup()` | `POST /api/account/mfa/totp/setup` |
| `useAccountMfaVerify()` | `POST /api/account/mfa/totp/verify` |
| `useAccountBackupCodes()` | `POST /api/account/mfa/backup-codes` |
| `useAccountMfaRemove()` | `DELETE /api/account/mfa/totp` |
| `useAccountPasskeyList()` | `GET /api/account/mfa/webauthn` |
| `useAccountRenamePasskey()` | `PATCH /api/account/mfa/webauthn/{id}/name` |
| `useAccountDeletePasskey()` | `DELETE /api/account/mfa/webauthn/{id}` |
| `useAccountMfaPreference()` | `POST /api/account/mfa/method-preference` |
Old hooks in `tenant-hooks.ts` (`useMfaStatus`, `useMfaSetup`, etc.) are replaced with re-exports from `account-hooks.ts` to avoid breaking any remaining references during migration.
### User Menu in Header
Dropdown on the user name/avatar in the app header:
- "Account Settings" → `/settings/account`
- "Sign Out" → existing sign-out flow
---
## Section 4: Vendor Admin List Page
### New Route
`/vendor/admins` — added to vendor sidebar navigation.
### Page Layout
- Header: "Platform Administrators" + "Add Administrator" button
- Table columns: Display Name, Email, Status ("You" badge on self-row), actions
- Row actions (kebab menu): Reset Password, Reset MFA, Remove
- Self-row: remove action disabled
### Add Administrator Dialog
- Checks email connector status via existing `GET /api/vendor/email/status`
- Email connector configured: single email field → invite sent
- Email connector not configured: email field + temporary password field → credentials shown with copy action on success
### Confirmation Dialogs
- Remove: "Remove {name} as platform administrator? They will lose access to the vendor console."
- Reset Password: form with new temporary password field
- Reset MFA: "Reset all MFA enrollments for {name}? They will need to re-enroll."
### New API Hooks
`ui/src/hooks/vendor-admin-hooks.ts`:
| Hook | Endpoint |
|------|----------|
| `useVendorAdminList()` | `GET /api/vendor/admins` |
| `useCreateVendorAdmin()` | `POST /api/vendor/admins` |
| `useRemoveVendorAdmin()` | `DELETE /api/vendor/admins/{userId}` |
| `useResetVendorAdminPassword()` | `POST /api/vendor/admins/{userId}/reset-password` |
| `useResetVendorAdminMfa()` | `DELETE /api/vendor/admins/{userId}/mfa` |
---
## Section 5: Sign-In Page Changes
### Forgot Password Link
Add a "Forgot password?" link below the password field in `SignInPage.tsx`. This triggers the existing `forgotPassword` mode (already implemented at lines 187-238). The flow:
1. User enters email
2. Logto Experience API sends reset code
3. User enters code + new password
4. On success, fires `POST /api/password-reset-notification` (already wired)
No backend changes needed — the flow exists, just needs a visible trigger in the UI.
---
## Files Changed Summary
### New Files
| File | Purpose |
|------|---------|
| `src/.../account/AccountService.java` | User-level identity operations |
| `src/.../account/AccountController.java` | `/api/account/*` endpoints |
| `src/.../vendor/VendorAdminService.java` | Vendor admin CRUD |
| `src/.../vendor/VendorAdminController.java` | `/api/vendor/admins/*` endpoints |
| `ui/src/components/account/ProfileSection.tsx` | Display name + email |
| `ui/src/components/account/PasswordChangeSection.tsx` | Password change form |
| `ui/src/components/account/MfaSection.tsx` | TOTP management |
| `ui/src/components/account/PasskeySection.tsx` | Passkey list/rename/delete |
| `ui/src/pages/AccountSettingsPage.tsx` | Shared account settings page |
| `ui/src/pages/vendor/VendorAdminsPage.tsx` | Vendor admin list page |
| `ui/src/hooks/account-hooks.ts` | Shared account API hooks |
| `ui/src/hooks/vendor-admin-hooks.ts` | Vendor admin API hooks |
### Modified Files
| File | Change |
|------|--------|
| `LogtoManagementClient.java` | Add `verifyPasswordViaRopc`, `listRoleUsers`, `assignGlobalRole`, `revokeGlobalRole`, `getRoleByName` |
| `SecurityConfig.java` | Add `/api/account/**` as `authenticated()` |
| `MfaEnforcementFilter.java` | Exempt `/api/account/mfa/` paths |
| `TenantPortalService.java` | Delegate MFA/password/passkey methods to `AccountService` |
| `TenantPortalController.java` | Optionally deprecate old `/api/tenant/mfa/*` endpoints |
| `OnboardingService.java` | Use `AccountService.updateDisplayName()` instead of direct Logto call |
| `SettingsPage.tsx` (tenant) | Import shared components from `components/account/` |
| `tenant-hooks.ts` | Replace MFA/password hooks with re-exports from `account-hooks.ts` |
| `router.tsx` | Add `/settings/account` and `/vendor/admins` routes |
| `SignInPage.tsx` | Add "Forgot password?" link |
| App header component | Add user dropdown menu |

View File

@@ -26,13 +26,12 @@ Both audiences share the same UI and workflows. The self-hosted setup section at
### Logging In
Cameleer SaaS uses Logto for single sign-on (SSO). To log in:
Cameleer SaaS uses Logto for single sign-on (SSO). Email is the primary user identity — all users must have an email address. To log in:
1. Navigate to the Cameleer SaaS URL in your browser.
2. You will see the login screen with the title "Cameleer SaaS" and a subtitle "Managed Apache Camel Runtime."
3. Click **Sign in with Logto**.
4. Authenticate with your Logto credentials (username/password or any configured social login).
5. After successful authentication, you are redirected back to the dashboard.
2. You will be redirected to the Cameleer sign-in page.
3. Enter your email and password.
4. After successful authentication, you are redirected to the dashboard.
![Login page](screenshots/login-page.png)
@@ -62,16 +61,28 @@ The dashboard provides an at-a-glance overview of your tenant:
The sidebar provides access to all major sections:
**Vendor console** (visible only to platform admins):
| Section | Description |
|---------|-------------|
| **Dashboard** | Tenant overview and KPI metrics |
| **Environments** | Expandable tree showing all environments and their apps |
| **License** | License tier, features, limits, and token |
| **Platform** | Platform-wide tenant management (visible only to platform admins) |
| **View Dashboard** | Opens the observability dashboard (cameleer-server) in a new tab |
| **Account** | Log out of the current session |
| **Tenants** | List, create, and manage tenants |
| **Audit Log** | Platform-wide audit trail |
| **Certificates** | TLS certificate lifecycle (stage, activate, restore) |
| **Metrics** | Tenant usage metrics |
| **Infrastructure** | PostgreSQL and ClickHouse health |
| **Email Connector** | Configure SMTP for email verification and self-service registration |
| **Logto Console** | Opens the Logto admin console (external link) |
The Environments section in the sidebar renders as a collapsible tree: environments at the top level, with their applications nested underneath. Clicking any item navigates directly to its detail page.
**Tenant portal** (visible to tenant admins):
| Section | Description |
|---------|-------------|
| **Dashboard** | Tenant overview, server health, usage, and quick links |
| **License** | License tier, features, limits, and token |
| **Security** | SSO connector configuration |
| **Team** | Manage team members and reset passwords |
| **Audit Log** | Tenant-scoped audit trail |
| **Settings** | Change own password, reset server admin password |
---
@@ -442,7 +453,7 @@ Copy `.env.example` to `.env` and configure as needed:
| `CAMELEER_SAAS_IDENTITY_SPACLIENTID` | SPA client ID for the frontend | _(empty)_ |
| `PUBLIC_HOST` | Public hostname for Traefik, Logto, and SaaS routing | `localhost` |
| `PUBLIC_PROTOCOL` | Public protocol (`http` or `https`) | `https` |
| `SAAS_ADMIN_USER` | Platform admin username | `admin` |
| `SAAS_ADMIN_USER` | Platform admin login (must be an email in SaaS mode) | `admin` |
| `SAAS_ADMIN_PASS` | Platform admin password | `admin` |
| `TENANT_ADMIN_USER` | Tenant admin username | `camel` |
| `TENANT_ADMIN_PASS` | Tenant admin password | `camel` |
@@ -518,7 +529,7 @@ The Cameleer SaaS application itself does not need any changes -- all identity c
### Login Fails or Redirect Loop
**Symptoms:** Clicking "Sign in with Logto" redirects you in a loop, or you see an error page.
**Symptoms:** The sign-in page redirects in a loop, or you see an error page.
**Possible causes:**

1
installer Submodule

Submodule installer added at 531a17397b

View File

@@ -1,32 +0,0 @@
# Installer
## Deployment Modes
The installer (`installer/install.sh`) supports two deployment modes:
| | Multi-tenant SaaS (`DEPLOYMENT_MODE=saas`) | Standalone (`DEPLOYMENT_MODE=standalone`) |
|---|---|---|
| **Containers** | traefik, postgres, clickhouse, logto, cameleer-saas | traefik, postgres, clickhouse, server, server-ui |
| **Auth** | Logto OIDC (SaaS admin + tenant users) | Local auth (built-in admin, no identity provider) |
| **Tenant management** | SaaS admin creates/manages tenants via UI | Single server instance, no fleet management |
| **PostgreSQL** | `cameleer-postgres` image (multi-DB init) | Stock `postgres:16-alpine` (server creates schema via Flyway) |
| **Use case** | Platform vendor managing multiple customers | Single customer running the product directly |
Standalone mode generates a simpler compose with the server running directly. No Logto, no SaaS management plane, no bootstrap. The admin logs in with local credentials at `/`.
## Compose templates
The installer uses static docker-compose templates in `installer/templates/`. Templates are copied to the install directory and composed via `COMPOSE_FILE` in `.env`:
- `docker-compose.yml` — shared infrastructure (traefik, postgres, clickhouse)
- `docker-compose.saas.yml` — SaaS mode (logto, cameleer-saas)
- `docker-compose.server.yml` — standalone mode (server, server-ui)
- `docker-compose.tls.yml` — overlay: custom TLS cert volume
- `docker-compose.monitoring.yml` — overlay: external monitoring network
## Env var naming convention
- `CAMELEER_AGENT_*` — agent config (consumed by the Java agent)
- `CAMELEER_SERVER_*` — server config (consumed by cameleer-server)
- `CAMELEER_SAAS_*` — SaaS management plane config
- `CAMELEER_SAAS_PROVISIONING_*` — "SaaS forwards this to provisioned tenant servers"
- No prefix (e.g. `POSTGRES_PASSWORD`, `PUBLIC_HOST`) — shared infrastructure, consumed by multiple components

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,89 +0,0 @@
# Cameleer Configuration
# Copy this file to .env and fill in the values.
# The installer generates .env automatically — this file is for reference.
# ============================================================
# Compose file assembly (set by installer)
# ============================================================
# SaaS: docker-compose.yml:docker-compose.saas.yml
# Standalone: docker-compose.yml:docker-compose.server.yml
# Add :docker-compose.tls.yml for custom TLS certificates
# Add :docker-compose.monitoring.yml for external monitoring network
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml
# ============================================================
# Image version
# ============================================================
VERSION=latest
# ============================================================
# Public access
# ============================================================
PUBLIC_HOST=localhost
PUBLIC_PROTOCOL=https
# ============================================================
# Ports
# ============================================================
HTTP_PORT=80
HTTPS_PORT=443
# Set to 0.0.0.0 to expose Logto admin console externally (default: localhost only)
# LOGTO_CONSOLE_BIND=0.0.0.0
LOGTO_CONSOLE_PORT=3002
# ============================================================
# PostgreSQL
# ============================================================
POSTGRES_USER=cameleer
POSTGRES_PASSWORD=CHANGE_ME
# SaaS: cameleer_saas, Standalone: cameleer
POSTGRES_DB=cameleer_saas
# ============================================================
# ClickHouse
# ============================================================
CLICKHOUSE_PASSWORD=CHANGE_ME
# ============================================================
# Admin credentials (SaaS mode)
# ============================================================
SAAS_ADMIN_USER=admin
SAAS_ADMIN_PASS=CHANGE_ME
# ============================================================
# Admin credentials (standalone mode)
# ============================================================
# SERVER_ADMIN_USER=admin
# SERVER_ADMIN_PASS=CHANGE_ME
# BOOTSTRAP_TOKEN=CHANGE_ME
# ============================================================
# TLS
# ============================================================
# Set to 1 to reject unauthorized TLS certificates (production)
NODE_TLS_REJECT=0
# Custom TLS certificate paths (inside container, set by installer)
# CERT_FILE=/user-certs/cert.pem
# KEY_FILE=/user-certs/key.pem
# CA_FILE=/user-certs/ca.pem
# ============================================================
# Docker
# ============================================================
DOCKER_SOCKET=/var/run/docker.sock
# GID of the docker socket — detected by installer, used for container group_add
DOCKER_GID=0
# ============================================================
# Provisioning images (SaaS mode only)
# ============================================================
# CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest
# CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest
# CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=gitea.siegeln.net/cameleer/cameleer-runtime-base:latest
# ============================================================
# Monitoring (optional)
# ============================================================
# External Docker network name for Prometheus scraping.
# Only needed when docker-compose.monitoring.yml is in COMPOSE_FILE.
# MONITORING_NETWORK=prometheus

View File

@@ -1,7 +0,0 @@
# External monitoring network overlay
# Overrides the noop monitoring bridge with a real external network
networks:
monitoring:
external: true
name: ${MONITORING_NETWORK:?MONITORING_NETWORK must be set in .env}

View File

@@ -1,108 +0,0 @@
# Cameleer SaaS — Logto + management plane
# Loaded in SaaS deployment mode
services:
cameleer-logto:
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
restart: unless-stopped
depends_on:
cameleer-postgres:
condition: service_healthy
environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@cameleer-postgres:5432/logto
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
TRUST_PROXY_HEADER: 1
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
LOGTO_ENDPOINT: http://cameleer-logto:3001
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
PG_HOST: cameleer-postgres
PG_USER: ${POSTGRES_USER:-cameleer}
PG_PASSWORD: ${POSTGRES_PASSWORD}
PG_DB_SAAS: cameleer_saas
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
interval: 10s
timeout: 5s
retries: 60
start_period: 30s
labels:
- traefik.enable=true
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`)
- traefik.http.routers.cameleer-logto.priority=1
- traefik.http.routers.cameleer-logto.entrypoints=websecure
- traefik.http.routers.cameleer-logto.tls=true
- traefik.http.routers.cameleer-logto.service=cameleer-logto
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
- traefik.http.services.cameleer-logto.loadbalancer.server.port=3001
- traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`)
- traefik.http.routers.cameleer-logto-console.entrypoints=admin-console
- traefik.http.routers.cameleer-logto-console.tls=true
- traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console
- traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002
volumes:
- cameleer-bootstrapdata:/data
networks:
- cameleer
- monitoring
cameleer-saas:
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
restart: unless-stopped
depends_on:
cameleer-logto:
condition: service_healthy
environment:
# SaaS database
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/cameleer_saas
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Identity (Logto)
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-logto:3001
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
# Provisioning — passed to per-tenant server containers
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer}
CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD}
CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD}
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:?CAMELEER_SERVER_SECURITY_JWTSECRET must be set in .env}
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:-gitea.siegeln.net/cameleer/cameleer-server:latest}
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui:latest}
CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE: ${CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE:-gitea.siegeln.net/cameleer/cameleer-runtime-base:latest}
labels:
- traefik.enable=true
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
- traefik.http.routers.saas.entrypoints=websecure
- traefik.http.routers.saas.tls=true
- traefik.http.services.saas.loadbalancer.server.port=8080
- "prometheus.io/scrape=true"
- "prometheus.io/port=8080"
- "prometheus.io/path=/platform/actuator/prometheus"
volumes:
- cameleer-bootstrapdata:/data/bootstrap:ro
- cameleer-certs:/certs
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-0}"
networks:
- cameleer
- monitoring
volumes:
cameleer-bootstrapdata:
networks:
monitoring:
name: cameleer-monitoring-noop

View File

@@ -1,99 +0,0 @@
# Cameleer Server (standalone)
# Loaded in standalone deployment mode
services:
cameleer-traefik:
volumes:
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
cameleer-postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-cameleer}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer}"]
cameleer-server:
image: ${SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server}:${VERSION:-latest}
container_name: cameleer-server
restart: unless-stopped
depends_on:
cameleer-postgres:
condition: service_healthy
environment:
CAMELEER_SERVER_TENANT_ID: default
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/${POSTGRES_DB:-cameleer}?currentSchema=tenant_default
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
CAMELEER_SERVER_CLICKHOUSE_URL: jdbc:clickhouse://cameleer-clickhouse:8123/cameleer
CAMELEER_SERVER_CLICKHOUSE_USERNAME: default
CAMELEER_SERVER_CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN: ${BOOTSTRAP_TOKEN:?BOOTSTRAP_TOKEN must be set in .env}
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:?CAMELEER_SERVER_SECURITY_JWTSECRET must be set in .env}
CAMELEER_SERVER_SECURITY_UIUSER: ${SERVER_ADMIN_USER:-admin}
CAMELEER_SERVER_SECURITY_UIPASSWORD: ${SERVER_ADMIN_PASS:?SERVER_ADMIN_PASS must be set in .env}
CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
CAMELEER_SERVER_RUNTIME_ENABLED: "true"
CAMELEER_SERVER_RUNTIME_SERVERURL: http://cameleer-server:8081
CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN: ${PUBLIC_HOST:-localhost}
CAMELEER_SERVER_RUNTIME_ROUTINGMODE: path
CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH: /data/jars
CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-apps
CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME: cameleer-jars
CAMELEER_SERVER_RUNTIME_BASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:${VERSION:-latest}
labels:
- traefik.enable=true
- traefik.http.routers.server-api.rule=PathPrefix(`/api`)
- traefik.http.routers.server-api.entrypoints=websecure
- traefik.http.routers.server-api.tls=true
- traefik.http.services.server-api.loadbalancer.server.port=8081
- traefik.docker.network=cameleer-traefik
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
volumes:
- jars:/data/jars
- cameleer-certs:/certs:ro
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-0}"
networks:
- cameleer
- cameleer-traefik
- cameleer-apps
- monitoring
cameleer-server-ui:
image: ${SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui}:${VERSION:-latest}
restart: unless-stopped
depends_on:
cameleer-server:
condition: service_healthy
environment:
CAMELEER_API_URL: http://cameleer-server:8081
BASE_PATH: ""
labels:
- traefik.enable=true
- traefik.http.routers.ui.rule=PathPrefix(`/`)
- traefik.http.routers.ui.priority=1
- traefik.http.routers.ui.entrypoints=websecure
- traefik.http.routers.ui.tls=true
- traefik.http.services.ui.loadbalancer.server.port=80
- traefik.docker.network=cameleer-traefik
networks:
- cameleer-traefik
- monitoring
volumes:
jars:
name: cameleer-jars
networks:
cameleer-apps:
name: cameleer-apps
driver: bridge
monitoring:
name: cameleer-monitoring-noop

View File

@@ -1,7 +0,0 @@
# Custom TLS certificates overlay
# Adds user-supplied certificate volume to traefik
services:
cameleer-traefik:
volumes:
- ./certs:/user-certs:ro

View File

@@ -1,79 +0,0 @@
# Cameleer Infrastructure
# Shared base — always loaded. Mode-specific services in separate compose files.
services:
cameleer-traefik:
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
- "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002"
environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
CERT_FILE: ${CERT_FILE:-}
KEY_FILE: ${KEY_FILE:-}
CA_FILE: ${CA_FILE:-}
volumes:
- cameleer-certs:/certs
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
labels:
- "prometheus.io/scrape=true"
- "prometheus.io/port=8082"
- "prometheus.io/path=/metrics"
networks:
- cameleer
- cameleer-traefik
- monitoring
cameleer-postgres:
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
volumes:
- cameleer-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer_saas}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- cameleer
- monitoring
cameleer-clickhouse:
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
restart: unless-stopped
environment:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?CLICKHOUSE_PASSWORD must be set in .env}
volumes:
- cameleer-chdata:/var/lib/clickhouse
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --password $${CLICKHOUSE_PASSWORD} --query 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 3
labels:
- "prometheus.io/scrape=true"
- "prometheus.io/port=9363"
- "prometheus.io/path=/metrics"
networks:
- cameleer
- monitoring
volumes:
cameleer-pgdata:
cameleer-chdata:
cameleer-certs:
networks:
cameleer:
driver: bridge
cameleer-traefik:
name: cameleer-traefik
driver: bridge
monitoring:
name: cameleer-monitoring-noop

View File

@@ -1,6 +0,0 @@
tls:
stores:
default:
defaultCertificate:
certFile: /certs/cert.pem
keyFile: /certs/key.pem

23
pom.xml
View File

@@ -100,6 +100,19 @@
<version>3.4.1</version>
</dependency>
<!-- License Minter (Ed25519 signing) -->
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-license-minter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!-- Mail (for password-reset security notification) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -123,6 +136,16 @@
</dependency>
</dependencies>
<repositories>
<repository>
<id>gitea-cameleer</id>
<url>https://gitea.siegeln.net/api/packages/cameleer/maven</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<build>
<plugins>
<plugin>

View File

@@ -0,0 +1,114 @@
package net.siegeln.cameleer.saas.account;
import net.siegeln.cameleer.saas.account.AccountService.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/account")
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
// --- Profile ---
@GetMapping("/profile")
public ProfileData getProfile(@AuthenticationPrincipal Jwt jwt) {
return accountService.getProfile(jwt.getSubject());
}
@PatchMapping("/profile")
public ResponseEntity<Void> updateProfile(@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, String> body) {
String name = body.get("name");
accountService.updateDisplayName(jwt.getSubject(), name);
return ResponseEntity.noContent().build();
}
// --- Password ---
record PasswordChangeRequest(String currentPassword, String newPassword) {}
@PostMapping("/password")
public ResponseEntity<Void> changePassword(@AuthenticationPrincipal Jwt jwt,
@RequestBody PasswordChangeRequest request) {
accountService.changePassword(jwt.getSubject(), request.currentPassword(), request.newPassword());
return ResponseEntity.noContent().build();
}
// --- MFA ---
@GetMapping("/mfa/status")
public MfaStatusData getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
return accountService.getMfaStatus(jwt.getSubject());
}
@PostMapping("/mfa/totp/setup")
public MfaSetupData setupTotp(@AuthenticationPrincipal Jwt jwt) {
return accountService.setupTotp(jwt.getSubject());
}
record TotpVerifyRequest(String secret, String code) {}
@PostMapping("/mfa/totp/verify")
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
@RequestBody TotpVerifyRequest request) {
boolean ok = accountService.verifyAndEnableTotp(jwt.getSubject(), request.secret(), request.code());
return Map.of("verified", ok);
}
@PostMapping("/mfa/backup-codes")
public BackupCodesData generateBackupCodes(@AuthenticationPrincipal Jwt jwt) {
return accountService.generateBackupCodes(jwt.getSubject());
}
@DeleteMapping("/mfa/totp")
public ResponseEntity<Void> removeTotp(@AuthenticationPrincipal Jwt jwt) {
accountService.removeMfa(jwt.getSubject());
return ResponseEntity.noContent().build();
}
// --- Passkeys ---
@GetMapping("/mfa/webauthn")
public List<PasskeyCredential> listPasskeys(@AuthenticationPrincipal Jwt jwt) {
return accountService.listPasskeys(jwt.getSubject());
}
@PatchMapping("/mfa/webauthn/{id}/name")
public ResponseEntity<Void> renamePasskey(@AuthenticationPrincipal Jwt jwt,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String name = body.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().build();
}
accountService.renamePasskey(jwt.getSubject(), id, name.trim());
return ResponseEntity.noContent().build();
}
@DeleteMapping("/mfa/webauthn/{id}")
public ResponseEntity<Void> deletePasskey(@AuthenticationPrincipal Jwt jwt,
@PathVariable String id) {
accountService.deletePasskey(jwt.getSubject(), id);
return ResponseEntity.noContent().build();
}
// --- MFA Preference ---
@PostMapping("/mfa/method-preference")
public ResponseEntity<Void> setMfaPreference(@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, String> body) {
accountService.setMfaMethodPreference(jwt.getSubject(), body.get("preference"));
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,263 @@
package net.siegeln.cameleer.saas.account;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.notification.PasswordResetNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Service
public class AccountService {
private static final Logger log = LoggerFactory.getLogger(AccountService.class);
private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private final LogtoManagementClient logtoClient;
private final PasswordResetNotificationService passwordNotificationService;
public AccountService(LogtoManagementClient logtoClient,
PasswordResetNotificationService passwordNotificationService) {
this.logtoClient = logtoClient;
this.passwordNotificationService = passwordNotificationService;
}
// --- Records ---
public record ProfileData(String userId, String name, String email) {}
public record MfaStatusData(boolean enrolled, boolean hasBackupCodes, boolean passkeyEnrolled, int passkeyCount) {}
public record MfaSetupData(String secret, String secretQrCode) {}
public record BackupCodesData(List<String> codes) {}
public record PasskeyCredential(String id, String name, String agent, String createdAt) {}
// --- Profile ---
public ProfileData getProfile(String userId) {
var user = logtoClient.getUser(userId);
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
}
Object nameVal = user.get("name");
Object emailVal = user.get("primaryEmail");
return new ProfileData(
userId,
nameVal != null ? String.valueOf(nameVal) : "",
emailVal != null ? String.valueOf(emailVal) : ""
);
}
public void updateDisplayName(String userId, String name) {
if (name == null || name.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Display name must not be blank");
}
logtoClient.updateUserProfile(userId, Map.of("name", name.trim()));
}
// --- Password ---
public void validatePassword(String password) {
if (password == null || password.length() < 8) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password must be at least 8 characters");
}
}
public void changePassword(String userId, String currentPassword, String newPassword) {
validatePassword(newPassword);
if (!logtoClient.verifyUserPassword(userId, currentPassword)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Current password is incorrect");
}
logtoClient.updateUserPassword(userId, newPassword);
// Send confirmation email asynchronously
try {
var user = logtoClient.getUser(userId);
if (user != null) {
String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
if (!email.isBlank()) {
passwordNotificationService.sendNotification(email);
}
}
} catch (Exception e) {
log.warn("Failed to send password change notification for user {}: {}", userId, e.getMessage());
}
}
// --- MFA ---
public MfaStatusData getMfaStatus(String userId) {
var verifications = logtoClient.getUserMfaVerifications(userId);
boolean enrolled = verifications.stream()
.anyMatch(v -> "Totp".equals(String.valueOf(v.get("type"))));
boolean hasBackupCodes = verifications.stream()
.anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type"))));
long passkeyCount = verifications.stream()
.filter(v -> "WebAuthn".equals(String.valueOf(v.get("type"))))
.count();
return new MfaStatusData(enrolled, hasBackupCodes, passkeyCount > 0, (int) passkeyCount);
}
public MfaSetupData setupTotp(String userId) {
byte[] secretBytes = new byte[20];
new SecureRandom().nextBytes(secretBytes);
String secret = base32Encode(secretBytes);
// Build otpauth URI locally — do NOT register with Logto yet.
// The secret is only registered after the user verifies the 6-digit code.
var user = logtoClient.getUser(userId);
String email = user != null ? String.valueOf(user.getOrDefault("primaryEmail", "")) : "";
String account = email.isBlank() ? userId : email;
// Include org name in issuer so authenticator apps show "Cameleer - OrgName"
String issuer = "Cameleer";
var orgs = logtoClient.getUserOrganizations(userId);
if (!orgs.isEmpty()) {
issuer = "Cameleer - " + orgs.getFirst().get("name");
}
String encodedIssuer = java.net.URLEncoder.encode(issuer, java.nio.charset.StandardCharsets.UTF_8);
String encodedAccount = java.net.URLEncoder.encode(account, java.nio.charset.StandardCharsets.UTF_8);
String otpauthUri = String.format(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
encodedIssuer, encodedAccount, secret, encodedIssuer);
return new MfaSetupData(secret, otpauthUri);
}
public boolean verifyAndEnableTotp(String userId, String secret, String code) {
if (!verifyTotpCode(secret, code)) return false;
logtoClient.createTotpVerification(userId, secret);
return true;
}
public boolean verifyTotpCode(String secret, String code) {
if (code == null || code.length() != 6) return false;
long currentStep = Instant.now().getEpochSecond() / 30;
for (int drift = -1; drift <= 1; drift++) {
String computed = computeTotp(secret, currentStep + drift);
if (code.equals(computed)) return true;
}
return false;
}
public BackupCodesData generateBackupCodes(String userId) {
var result = logtoClient.createBackupCodes(userId);
@SuppressWarnings("unchecked")
List<String> codes = (List<String>) result.get("codes");
return new BackupCodesData(codes != null ? codes : List.of());
}
public void removeMfa(String userId) {
var verifications = logtoClient.getUserMfaVerifications(userId);
for (var v : verifications) {
logtoClient.deleteMfaVerification(userId, String.valueOf(v.get("id")));
}
}
// --- Passkeys ---
public List<PasskeyCredential> listPasskeys(String userId) {
var credentials = logtoClient.getWebAuthnCredentials(userId);
return credentials.stream()
.map(c -> new PasskeyCredential(
String.valueOf(c.get("id")),
c.get("name") != null ? String.valueOf(c.get("name")) : null,
c.get("agent") != null ? String.valueOf(c.get("agent")) : null,
c.get("createdAt") != null ? String.valueOf(c.get("createdAt")) : null
))
.toList();
}
public void renamePasskey(String userId, String credentialId, String name) {
var credentials = logtoClient.getWebAuthnCredentials(userId);
boolean owns = credentials.stream()
.anyMatch(c -> credentialId.equals(String.valueOf(c.get("id"))));
if (!owns) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
}
logtoClient.renameMfaVerification(userId, credentialId, name);
}
public void deletePasskey(String userId, String credentialId) {
var credentials = logtoClient.getWebAuthnCredentials(userId);
boolean owns = credentials.stream()
.anyMatch(c -> credentialId.equals(String.valueOf(c.get("id"))));
if (!owns) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
}
logtoClient.deleteMfaVerification(userId, credentialId);
}
// --- MFA Preference ---
public void setMfaMethodPreference(String userId, String preference) {
if (!"totp".equals(preference) && !"webauthn".equals(preference)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid MFA preference: must be 'totp' or 'webauthn'");
}
logtoClient.updateUserCustomData(userId, Map.of("mfa_method_preference", preference));
}
// --- TOTP helpers (moved from TenantPortalService) ---
private String computeTotp(String base32Secret, long timeStep) {
try {
byte[] key = base32Decode(base32Secret);
byte[] data = ByteBuffer.allocate(8).putLong(timeStep).array();
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
byte[] hash = mac.doFinal(data);
int offset = hash[hash.length - 1] & 0x0F;
int code = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
return String.format("%06d", code % 1_000_000);
} catch (Exception e) {
log.error("TOTP computation failed", e);
return "";
}
}
String base32Encode(byte[] data) {
StringBuilder sb = new StringBuilder();
int buffer = 0, bitsLeft = 0;
for (byte b : data) {
buffer = (buffer << 8) | (b & 0xFF);
bitsLeft += 8;
while (bitsLeft >= 5) {
sb.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
bitsLeft -= 5;
}
}
if (bitsLeft > 0) {
sb.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F));
}
return sb.toString();
}
byte[] base32Decode(String encoded) {
String clean = encoded.replaceAll("[=\\s]", "").toUpperCase();
int byteCount = clean.length() * 5 / 8;
byte[] result = new byte[byteCount];
int buffer = 0, bitsLeft = 0, index = 0;
for (char c : clean.toCharArray()) {
int val = BASE32_ALPHABET.indexOf(c);
if (val < 0) continue;
buffer = (buffer << 5) | val;
bitsLeft += 5;
if (bitsLeft >= 8) {
result[index++] = (byte) (buffer >> (bitsLeft - 8));
bitsLeft -= 8;
}
}
return result;
}
}

View File

@@ -1,5 +1,9 @@
# Auth & Security Config
## User identity
**Email is the primary user identity** in SaaS mode. All users must have an email address — Logto enforces this via `signUp.identifiers: ["email"]` when registration is enabled. `SAAS_ADMIN_USER` IS the email address (no separate `SAAS_ADMIN_EMAIL`). The bootstrap creates the admin user with `SAAS_ADMIN_USER` as both username and `primaryEmail`. The installer enforces email format in SaaS mode. Self-service registration requires email verification via a configured email connector (vendor UI at `/vendor/email`).
## Auth enforcement
- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations
@@ -9,7 +13,24 @@
- Org roles: `owner` -> `server:admin` + `tenant:manage`, `operator` -> `server:operator`, `viewer` -> `server:viewer`
- `saas-vendor` global role created by bootstrap Phase 12 and always assigned to the admin user — has `platform:admin` + all tenant scopes
- Custom `JwtDecoder` in `SecurityConfig.java` — ES384 algorithm, `at+jwt` token type, split issuer-uri (string validation) / jwk-set-uri (Docker-internal fetch), audience validation (`https://api.cameleer.local`)
- Logto Custom JWT (Phase 7b in bootstrap) injects a `roles` claim into access tokens based on org roles and global roles — this makes role data available to the server without Logto-specific code
- Logto Custom JWT (Phase 7b in bootstrap) injects claims into access tokens: `roles` (org role mapping), `mfa_enrolled` (true if TOTP or WebAuthn factor), `passkey_enrolled` (true if WebAuthn factor), `mfa_method_preference` (from user custom data)
## MFA & Passkey enforcement
Two independent policy domains — **no inheritance**, vendor and tenant policies are separate scopes:
| Policy | Scope | Who it affects | Stored in |
|--------|-------|---------------|-----------|
| **Vendor auth policy** | Platform logins (`/api/vendor/**`, `/api/portal/**`) | Tenant admins | `vendor_auth_policy` table (single-row) |
| **Tenant auth policy** | Org user logins (`/api/tenant/**`) | Org members | `tenants.settings` JSONB (`mfaMode`, `passkeyEnabled`, `passkeyMode`) |
- `MfaEnforcementFilter` (after `BearerTokenAuthenticationFilter`) checks JWT claims `mfa_enrolled` and `passkey_enrolled` against effective policy
- Error codes: `APP_MFA_REQUIRED`, `APP_PASSKEY_REQUIRED` (returned via `X-Cameleer-Error` header)
- Exempt routes: `/api/tenant/mfa/`, `/api/config`, `/api/me`, `/api/onboarding`, `/api/vendor/auth-policy`, `/api/tenant/auth-settings`
- Backward-compatible: legacy `mfaRequired: true` in settings treated as `mfaMode: "required"`
- Policy settings: `mfa_mode` (off/optional/required), `passkey_enabled` (bool), `passkey_mode` (optional/preferred/required)
- Passkey registration only via Logto Experience API during sign-in (Approach A: Logto-native WebAuthn)
- Passkey management (list/rename/delete) via Logto Management API through SaaS backend endpoints
## Auth routing by persona
@@ -18,10 +39,13 @@
| SaaS admin | `saas-vendor` (global) | `platform:admin` | `/vendor/tenants` |
| Tenant admin | org `owner` | `tenant:manage` | `/tenant` (dashboard) |
| Regular user (operator/viewer) | org member | `server:operator` or `server:viewer` | Redirected to server dashboard directly |
| New user (just registered) | none (authenticated only) | none | `/onboarding` (self-service tenant creation) |
- `LandingRedirect` component waits for scopes to load, then routes to the correct persona landing page
- `LandingRedirect` component waits for scopes to load, then routes to the correct persona landing page. If user has zero organizations, redirects to `/onboarding`.
- `RequireScope` guard on route groups enforces scope requirements
- SSO bridge: Logto session carries over to provisioned server's OIDC flow (Traditional Web App per tenant)
- Self-service sign-up flow: `/platform/register` → Logto OIDC with `firstScreen: 'register'` → custom sign-in UI (email + password + verification code) → callback → `LandingRedirect``/onboarding``POST /api/onboarding/tenant` → tenant provisioned, user added as org owner
- `OnboardingController` at `/api/onboarding/**` requires `authenticated()` only (no specific scope). `OnboardingService` enforces one trial tenant per user, reuses `VendorTenantService.createAndProvision()`, and adds the calling user to the Logto org as `owner`.
## Server OIDC role extraction (two paths)

View File

@@ -0,0 +1,40 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* Ensures Logto sign-in experience always offers TOTP + WebAuthn + BackupCode
* on startup. Availability is always-on; enforcement is handled separately by
* MfaEnforcementFilter based on the vendor auth policy.
*/
@Component
public class LogtoStartupConfig {
private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
private final LogtoManagementClient logtoClient;
public LogtoStartupConfig(LogtoManagementClient logtoClient) {
this.logtoClient = logtoClient;
}
@EventListener(ApplicationReadyEvent.class)
public void onStartup() {
try {
List<String> factors = List.of("Totp", "WebAuthn", "BackupCode");
logtoClient.updateSignInExperience(Map.of(
"mfa", Map.of("factors", factors, "policy", "UserControlled")));
log.info("Logto MFA factors set to {} (UserControlled)", factors);
} catch (Exception e) {
log.warn("Failed to sync MFA factors on startup: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,163 @@
package net.siegeln.cameleer.saas.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
@Component
public class MfaEnforcementFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class);
private static final Set<String> EXEMPT_PREFIXES = Set.of(
"/api/tenant/mfa/",
"/api/account/mfa/",
"/api/account/profile",
"/api/account/password",
"/api/config",
"/api/me",
"/api/onboarding",
"/api/vendor/auth-policy",
"/api/tenant/auth-settings"
);
private final TenantService tenantService;
private final VendorAuthPolicyRepository vendorPolicyRepo;
private final ObjectMapper objectMapper;
public MfaEnforcementFilter(TenantService tenantService,
VendorAuthPolicyRepository vendorPolicyRepo,
ObjectMapper objectMapper) {
this.tenantService = tenantService;
this.vendorPolicyRepo = vendorPolicyRepo;
this.objectMapper = objectMapper;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
boolean isProtected = path.startsWith("/api/tenant/")
|| path.startsWith("/api/vendor/")
|| path.startsWith("/api/portal/");
if (!isProtected) return true;
return EXEMPT_PREFIXES.stream().anyMatch(path::startsWith);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken jwtAuth)) {
filterChain.doFilter(request, response);
return;
}
Jwt jwt = jwtAuth.getToken();
String path = request.getServletPath();
if (path.startsWith("/api/vendor/") || path.startsWith("/api/portal/")) {
enforceVendorPolicy(jwt, request, response, filterChain);
} else if (path.startsWith("/api/tenant/")) {
enforceTenantPolicy(jwt, request, response, filterChain);
} else {
filterChain.doFilter(request, response);
}
}
private void enforceVendorPolicy(Jwt jwt, HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
var policy = vendorPolicyRepo.getPolicy();
Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled");
Boolean passkeyEnrolled = jwt.getClaim("passkey_enrolled");
if ("required".equals(policy.getMfaMode()) && !Boolean.TRUE.equals(mfaEnrolled)) {
log.info("MFA enforcement (vendor): blocking user {} — vendor policy requires MFA", jwt.getSubject());
writeError(response, "APP_MFA_REQUIRED", "mfa_enrollment_required",
"Platform authentication policy requires multi-factor authentication");
return;
}
if (policy.isPasskeyEnabled() && "required".equals(policy.getPasskeyMode())
&& !Boolean.TRUE.equals(passkeyEnrolled)) {
log.info("Passkey enforcement (vendor): blocking user {} — vendor policy requires passkey", jwt.getSubject());
writeError(response, "APP_PASSKEY_REQUIRED", "passkey_enrollment_required",
"Platform authentication policy requires a passkey");
return;
}
filterChain.doFilter(request, response);
}
private void enforceTenantPolicy(Jwt jwt, HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled");
Boolean passkeyEnrolled = jwt.getClaim("passkey_enrolled");
String orgId = jwt.getClaimAsString("organization_id");
if (orgId == null) {
filterChain.doFilter(request, response);
return;
}
var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null);
if (tenant == null) {
filterChain.doFilter(request, response);
return;
}
Map<String, Object> settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of();
String mfaMode = settings.containsKey("mfaMode")
? String.valueOf(settings.get("mfaMode"))
: (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off");
if ("required".equals(mfaMode) && !Boolean.TRUE.equals(mfaEnrolled)) {
log.info("MFA enforcement: blocking user {} — tenant {} requires MFA", jwt.getSubject(), tenant.getSlug());
writeError(response, "APP_MFA_REQUIRED", "mfa_enrollment_required",
"Your organization requires multi-factor authentication");
return;
}
boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled"));
String passkeyMode = settings.containsKey("passkeyMode")
? String.valueOf(settings.get("passkeyMode"))
: "optional";
if (passkeyEnabled && "required".equals(passkeyMode) && !Boolean.TRUE.equals(passkeyEnrolled)) {
log.info("Passkey enforcement: blocking user {} — tenant {} requires passkey", jwt.getSubject(), tenant.getSlug());
writeError(response, "APP_PASSKEY_REQUIRED", "passkey_enrollment_required",
"Your organization requires a passkey");
return;
}
filterChain.doFilter(request, response);
}
private void writeError(HttpServletResponse response, String errorCode, String code, String message)
throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setHeader("X-Cameleer-Error", errorCode);
objectMapper.writeValue(response.getOutputStream(), Map.of(
"error", errorCode,
"code", code,
"message", message
));
}
}

View File

@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -25,6 +26,11 @@ public class PublicConfigController {
private String spaClientId;
private final ObjectMapper objectMapper = new ObjectMapper();
private final VendorAuthPolicyRepository vendorPolicyRepo;
public PublicConfigController(VendorAuthPolicyRepository vendorPolicyRepo) {
this.vendorPolicyRepo = vendorPolicyRepo;
}
private static final List<String> SCOPES = List.of(
"platform:admin",
@@ -61,11 +67,19 @@ public class PublicConfigController {
endpoint = "http://localhost:3001";
}
var policy = vendorPolicyRepo.getPolicy();
var vendorAuthPolicy = Map.of(
"mfaMode", policy.getMfaMode(),
"passkeyEnabled", policy.isPasskeyEnabled(),
"passkeyMode", policy.getPasskeyMode()
);
return Map.of(
"logtoEndpoint", endpoint,
"logtoClientId", clientId != null ? clientId : "",
"logtoResource", apiResource,
"scopes", SCOPES
"scopes", SCOPES,
"vendorAuthPolicy", vendorAuthPolicy
);
}

View File

@@ -24,6 +24,7 @@ import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import java.net.URL;
@@ -36,23 +37,27 @@ import java.util.List;
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain filterChain(HttpSecurity http, MfaEnforcementFilter mfaEnforcementFilter) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/config").permitAll()
.requestMatchers("/", "/index.html", "/login", "/callback",
"/vendor/**", "/tenant/**",
.requestMatchers("/", "/index.html", "/login", "/register", "/callback",
"/vendor/**", "/tenant/**", "/onboarding", "/settings/**",
"/environments/**", "/license", "/admin/**").permitAll()
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
.requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
.requestMatchers("/api/password-reset-notification").permitAll()
.requestMatchers("/api/account/**").authenticated()
.requestMatchers("/api/onboarding/**").authenticated()
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
.requestMatchers("/api/tenant/**").authenticated()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.addFilterAfter(mfaEnforcementFilter, BearerTokenAuthenticationFilter.class);
return http.build();
}

View File

@@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
public class SpaController {
@RequestMapping(value = {
"/", "/login", "/callback",
"/", "/login", "/register", "/callback", "/onboarding",
"/vendor/**", "/tenant/**"
})
public String forward() {

View File

@@ -209,11 +209,58 @@ public class LogtoManagementClient {
}
}
/** Create a user in Logto and add to organization with role. */
/** Delete a user from Logto entirely. */
public void deleteUser(String userId) {
if (!isAvailable() || userId == null) return;
try {
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
log.info("Deleted user {} from Logto", userId);
} catch (Exception e) {
log.warn("Failed to delete user {}: {}", userId, e.getMessage());
}
}
/** Find a user by email. Returns user map or null if not found. */
@SuppressWarnings("unchecked")
public Map<String, Object> findUserByEmail(String email) {
if (!isAvailable() || email == null) return null;
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/users?search="
+ java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8)
+ "&search.primaryEmail="
+ java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8)
+ "&page_size=5")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
if (resp == null) return null;
return ((List<Map<String, Object>>) resp).stream()
.filter(u -> email.equalsIgnoreCase(String.valueOf(u.get("primaryEmail"))))
.findFirst()
.orElse(null);
} catch (Exception e) {
log.warn("Failed to find user by email: {}", e.getMessage());
return null;
}
}
/** Create a user in Logto (or find existing by email) and add to organization with role. */
@SuppressWarnings("unchecked")
public String createAndInviteUser(String email, String orgId, String roleId) {
if (!isAvailable()) return null;
try {
String userId;
// Check if user already exists in Logto
var existing = findUserByEmail(email);
if (existing != null) {
userId = String.valueOf(existing.get("id"));
log.info("User '{}' already exists in Logto ({}), adding to org", email, userId);
} else {
var userResp = (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users")
.header("Authorization", "Bearer " + getAccessToken())
@@ -221,11 +268,14 @@ public class LogtoManagementClient {
.body(Map.of("primaryEmail", email, "name", email.split("@")[0]))
.retrieve()
.body(Map.class);
String userId = String.valueOf(userResp.get("id"));
userId = String.valueOf(userResp.get("id"));
}
if (orgId != null) {
addUserToOrganization(orgId, userId);
if (roleId != null) {
assignOrganizationRole(orgId, userId, roleId);
}
}
return userId;
} catch (Exception e) {
log.error("Failed to create and invite user: {}", e.getMessage());
@@ -233,7 +283,7 @@ public class LogtoManagementClient {
}
}
/** Create a user with username/password and add to org with role. */
/** Create a user with username/password and optionally add to org with role. */
@SuppressWarnings("unchecked")
public String createUserWithPassword(String username, String password, String orgId, String roleId) {
if (!isAvailable()) return null;
@@ -246,10 +296,12 @@ public class LogtoManagementClient {
.retrieve()
.body(Map.class);
String userId = String.valueOf(userResp.get("id"));
if (orgId != null) {
addUserToOrganization(orgId, userId);
if (roleId != null) {
assignOrganizationRole(orgId, userId, roleId);
}
}
log.info("Created user '{}' and added to org {} with role {}", username, orgId, roleId);
return userId;
} catch (Exception e) {
@@ -398,6 +450,122 @@ public class LogtoManagementClient {
.toBodilessEntity();
}
// --- Email Connector Management ---
/** List all connector factories available in Logto. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listConnectorFactories() {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/connector-factories")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list connector factories: {}", e.getMessage());
return List.of();
}
}
/** List all connectors. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listConnectors() {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/connectors")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list connectors: {}", e.getMessage());
return List.of();
}
}
/** Create a connector from a factory. */
@SuppressWarnings("unchecked")
public Map<String, Object> createConnector(String factoryId, Map<String, Object> connectorConfig) {
if (!isAvailable()) return null;
var body = new java.util.HashMap<String, Object>();
body.put("connectorId", factoryId);
body.put("config", connectorConfig);
return (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/connectors")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(Map.class);
}
/** Update an existing connector's config. */
@SuppressWarnings("unchecked")
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> connectorConfig) {
if (!isAvailable()) return null;
return (Map<String, Object>) restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("config", connectorConfig))
.retrieve()
.body(Map.class);
}
/** Delete a connector. */
public void deleteConnector(String connectorId) {
if (!isAvailable()) return;
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
}
/** Test a connector by sending a real email. Uses Logto's built-in test endpoint. */
public void testConnector(String factoryId, String email, Map<String, Object> connectorConfig) {
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/connectors/" + factoryId + "/test")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("email", email, "config", connectorConfig))
.retrieve()
.toBodilessEntity();
}
/** Get the current sign-in experience config. */
@SuppressWarnings("unchecked")
public Map<String, Object> getSignInExperience() {
if (!isAvailable()) return null;
try {
return (Map<String, Object>) restClient.get()
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(Map.class);
} catch (Exception e) {
log.warn("Failed to get sign-in experience: {}", e.getMessage());
return null;
}
}
/** Update the sign-in experience config (partial update). */
@SuppressWarnings("unchecked")
public Map<String, Object> updateSignInExperience(Map<String, Object> updates) {
if (!isAvailable()) return null;
return (Map<String, Object>) restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(updates)
.retrieve()
.body(Map.class);
}
/** Update a user's password. */
public void updateUserPassword(String userId, String newPassword) {
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
@@ -410,6 +578,157 @@ public class LogtoManagementClient {
.toBodilessEntity();
}
/** Verify a user's current password. Returns true if correct, false if wrong. */
public boolean verifyUserPassword(String userId, String password) {
try {
var token = getAccessToken();
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/password/verify")
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("password", password))
.retrieve()
.toBodilessEntity();
return true;
} catch (org.springframework.web.client.HttpClientErrorException e) {
if (e.getStatusCode().value() == 422 || e.getStatusCode().value() == 400) {
return false;
}
throw e;
}
}
// --- MFA Verification Management ---
/** List all MFA verifications for a user. Returns a list of MFA factor objects. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getUserMfaVerifications(String userId) {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to get MFA verifications for user {}: {}", userId, e.getMessage());
return List.of();
}
}
/** Create a TOTP MFA verification for a user. Returns the secret and QR code. */
@SuppressWarnings("unchecked")
public Map<String, Object> createTotpVerification(String userId, String secret) {
if (!isAvailable()) return Map.of();
try {
return (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("type", "Totp", "secret", secret))
.retrieve()
.body(Map.class);
} catch (Exception e) {
log.warn("Failed to create TOTP verification for user {}: {}", userId, e.getMessage());
return Map.of();
}
}
/** Generate backup codes for a user. Returns the list of codes. */
@SuppressWarnings("unchecked")
public Map<String, Object> createBackupCodes(String userId) {
if (!isAvailable()) return Map.of();
try {
return (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("type", "BackupCode"))
.retrieve()
.body(Map.class);
} catch (Exception e) {
log.warn("Failed to create backup codes for user {}: {}", userId, e.getMessage());
return Map.of();
}
}
/** Delete a specific MFA verification for a user. */
public void deleteMfaVerification(String userId, String verificationId) {
if (!isAvailable()) return;
try {
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications/" + verificationId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
log.warn("Failed to delete MFA verification {} for user {}: {}", verificationId, userId, e.getMessage());
}
}
/** Delete all MFA verifications for a user (used for admin MFA reset). */
public void deleteAllMfaVerifications(String userId) {
List<Map<String, Object>> verifications = getUserMfaVerifications(userId);
for (Map<String, Object> v : verifications) {
String id = String.valueOf(v.get("id"));
deleteMfaVerification(userId, id);
}
}
/** List WebAuthn credentials for a user (filtered from all MFA verifications). */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getWebAuthnCredentials(String userId) {
var all = getUserMfaVerifications(userId);
return all.stream()
.filter(v -> "WebAuthn".equals(String.valueOf(v.get("type"))))
.toList();
}
/** Rename a WebAuthn credential. Uses PATCH on the MFA verification. */
public void renameMfaVerification(String userId, String verificationId, String name) {
if (!isAvailable()) return;
try {
restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications/" + verificationId)
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("name", name))
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
log.warn("Failed to rename MFA verification {} for user {}: {}", verificationId, userId, e.getMessage());
}
}
/** Update user custom data (partial merge). Used for mfa_method_preference. */
public void updateUserCustomData(String userId, Map<String, Object> customData) {
if (!isAvailable()) return;
try {
restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/custom-data")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(customData)
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
log.warn("Failed to update custom data for user {}: {}", userId, e.getMessage());
}
}
/** Update a user's profile fields (e.g. name). */
public void updateUserProfile(String userId, Map<String, Object> profile) {
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(profile)
.retrieve()
.toBodilessEntity();
}
/** Get a user by ID. Returns username, primaryEmail, name. */
@SuppressWarnings("unchecked")
public Map<String, Object> getUser(String userId) {
@@ -426,6 +745,60 @@ public class LogtoManagementClient {
}
}
// --- Global Role Management ---
/** List all users assigned to a global role. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listRoleUsers(String roleId) {
var token = getAccessToken();
var response = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users")
.header("Authorization", "Bearer " + token)
.retrieve()
.body(List.class);
return response != null ? response : List.of();
}
/** Find a global role by exact name. Returns null if not found. */
@SuppressWarnings("unchecked")
public Map<String, Object> getRoleByName(String roleName) {
var token = getAccessToken();
var response = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/roles?search=" +
java.net.URLEncoder.encode(roleName, java.nio.charset.StandardCharsets.UTF_8) +
"&page_size=20")
.header("Authorization", "Bearer " + token)
.retrieve()
.body(List.class);
if (response == null) return null;
return ((List<Map<String, Object>>) response).stream()
.filter(r -> roleName.equals(r.get("name")))
.findFirst()
.orElse(null);
}
/** Assign a global role to a user. */
public void assignGlobalRole(String userId, String roleId) {
var token = getAccessToken();
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users")
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("userIds", List.of(userId)))
.retrieve()
.toBodilessEntity();
}
/** Revoke a global role from a user. */
public void revokeGlobalRole(String userId, String roleId) {
var token = getAccessToken();
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users/" + userId)
.header("Authorization", "Bearer " + token)
.retrieve()
.toBodilessEntity();
}
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
private synchronized String getAccessToken() {

View File

@@ -157,6 +157,22 @@ public class ServerApiClient {
}
}
/** Fetch app count from a tenant's server. */
public int getAppCount(String serverEndpoint) {
try {
var resp = RestClient.create().get()
.uri(serverEndpoint + "/api/v1/admin/apps")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.retrieve()
.body(java.util.List.class);
return resp != null ? resp.size() : 0;
} catch (Exception e) {
log.warn("App count fetch failed for {}: {}", serverEndpoint, e.getMessage());
return 0;
}
}
/** Reset the built-in admin password on a tenant's server. */
public void resetServerAdminPassword(String serverEndpoint, String newPassword) {
RestClient.create(serverEndpoint)

View File

@@ -55,15 +55,6 @@ public class LicenseController {
}
private LicenseResponse toResponse(LicenseEntity entity) {
return new LicenseResponse(
entity.getId(),
entity.getTenantId(),
entity.getTier(),
entity.getFeatures(),
entity.getLimits(),
entity.getIssuedAt(),
entity.getExpiresAt(),
entity.getToken()
);
return LicenseResponse.from(entity);
}
}

View File

@@ -8,37 +8,71 @@ public final class LicenseDefaults {
private LicenseDefaults() {}
public static Map<String, Object> featuresForTier(Tier tier) {
return switch (tier) {
case LOW -> Map.of(
"topology", true, "lineage", false,
"correlation", false, "debugger", false, "replay", false);
case MID -> Map.of(
"topology", true, "lineage", true,
"correlation", true, "debugger", false, "replay", false);
case HIGH -> Map.of(
"topology", true, "lineage", true,
"correlation", true, "debugger", true, "replay", true);
case BUSINESS -> Map.of(
"topology", true, "lineage", true,
"correlation", true, "debugger", true, "replay", true);
};
}
public static final int DEFAULT_GRACE_PERIOD_DAYS = 14;
public static final int DEFAULT_LICENSE_DAYS = 365;
public static Map<String, Object> limitsForTier(Tier tier) {
public static Map<String, Integer> limitsForTier(Tier tier) {
return switch (tier) {
case LOW -> Map.of(
"max_agents", 3, "retention_days", 7,
"max_environments", 1);
case MID -> Map.of(
"max_agents", 10, "retention_days", 30,
"max_environments", 2);
case HIGH -> Map.of(
"max_agents", 50, "retention_days", 90,
"max_environments", -1);
case BUSINESS -> Map.of(
"max_agents", -1, "retention_days", 365,
"max_environments", -1);
case STARTER -> Map.ofEntries(
Map.entry("max_environments", 2),
Map.entry("max_apps", 10),
Map.entry("max_agents", 20),
Map.entry("max_users", 5),
Map.entry("max_outbound_connections", 5),
Map.entry("max_alert_rules", 10),
Map.entry("max_total_cpu_millis", 8000),
Map.entry("max_total_memory_mb", 8192),
Map.entry("max_total_replicas", 25),
Map.entry("max_execution_retention_days", 7),
Map.entry("max_log_retention_days", 7),
Map.entry("max_metric_retention_days", 7),
Map.entry("max_jar_retention_count", 5)
);
case TEAM -> Map.ofEntries(
Map.entry("max_environments", 5),
Map.entry("max_apps", 50),
Map.entry("max_agents", 100),
Map.entry("max_users", 25),
Map.entry("max_outbound_connections", 25),
Map.entry("max_alert_rules", 50),
Map.entry("max_total_cpu_millis", 32000),
Map.entry("max_total_memory_mb", 32768),
Map.entry("max_total_replicas", 100),
Map.entry("max_execution_retention_days", 30),
Map.entry("max_log_retention_days", 30),
Map.entry("max_metric_retention_days", 30),
Map.entry("max_jar_retention_count", 10)
);
case BUSINESS -> Map.ofEntries(
Map.entry("max_environments", 10),
Map.entry("max_apps", 200),
Map.entry("max_agents", 500),
Map.entry("max_users", 100),
Map.entry("max_outbound_connections", 100),
Map.entry("max_alert_rules", 200),
Map.entry("max_total_cpu_millis", 128000),
Map.entry("max_total_memory_mb", 131072),
Map.entry("max_total_replicas", 500),
Map.entry("max_execution_retention_days", 90),
Map.entry("max_log_retention_days", 90),
Map.entry("max_metric_retention_days", 90),
Map.entry("max_jar_retention_count", 25)
);
case ENTERPRISE -> Map.ofEntries(
Map.entry("max_environments", 50),
Map.entry("max_apps", 1000),
Map.entry("max_agents", 5000),
Map.entry("max_users", 1000),
Map.entry("max_outbound_connections", 500),
Map.entry("max_alert_rules", 1000),
Map.entry("max_total_cpu_millis", 512000),
Map.entry("max_total_memory_mb", 524288),
Map.entry("max_total_replicas", 2000),
Map.entry("max_execution_retention_days", 365),
Map.entry("max_log_retention_days", 180),
Map.entry("max_metric_retention_days", 180),
Map.entry("max_jar_retention_count", 50)
);
};
}
}

View File

@@ -28,14 +28,16 @@ public class LicenseEntity {
@Column(name = "tier", nullable = false, length = 20)
private String tier;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> features;
@Column(name = "label")
private String label;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "limits", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> limits;
@Column(name = "grace_period_days", nullable = false)
private int gracePeriodDays;
@Column(name = "issued_at", nullable = false)
private Instant issuedAt;
@@ -62,10 +64,12 @@ public class LicenseEntity {
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public String getTier() { return tier; }
public void setTier(String tier) { this.tier = tier; }
public Map<String, Object> getFeatures() { return features; }
public void setFeatures(Map<String, Object> features) { this.features = features; }
public String getLabel() { return label; }
public void setLabel(String label) { this.label = label; }
public Map<String, Object> getLimits() { return limits; }
public void setLimits(Map<String, Object> limits) { this.limits = limits; }
public int getGracePeriodDays() { return gracePeriodDays; }
public void setGracePeriodDays(int gracePeriodDays) { this.gracePeriodDays = gracePeriodDays; }
public Instant getIssuedAt() { return issuedAt; }
public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; }
public Instant getExpiresAt() { return expiresAt; }

View File

@@ -1,12 +1,18 @@
package net.siegeln.cameleer.saas.license;
import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseValidator;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -14,27 +20,50 @@ import java.util.UUID;
@Service
public class LicenseService {
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
private final LicenseRepository licenseRepository;
private final AuditService auditService;
private final SigningKeyService signingKeyService;
public LicenseService(LicenseRepository licenseRepository, AuditService auditService) {
public LicenseService(LicenseRepository licenseRepository,
AuditService auditService,
SigningKeyService signingKeyService) {
this.licenseRepository = licenseRepository;
this.auditService = auditService;
this.signingKeyService = signingKeyService;
}
public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) {
var features = LicenseDefaults.featuresForTier(tenant.getTier());
var limits = LicenseDefaults.limitsForTier(tenant.getTier());
/**
* Mint an Ed25519-signed license with full control over limits.
*/
public LicenseEntity generateLicense(TenantEntity tenant,
Map<String, Integer> limits,
Instant expiresAt,
int gracePeriodDays,
String label,
UUID actorId) {
Instant now = Instant.now();
Instant expiresAt = now.plus(validity);
UUID licenseId = UUID.randomUUID();
String token = UUID.randomUUID().toString();
LicenseInfo info = new LicenseInfo(
licenseId,
tenant.getSlug(),
label,
limits,
now,
expiresAt,
gracePeriodDays
);
String token = LicenseMinter.mint(info, signingKeyService.getPrivateKey());
var entity = new LicenseEntity();
entity.setTenantId(tenant.getId());
entity.setTier(tenant.getTier().name());
entity.setFeatures(features);
entity.setLimits(limits);
entity.setLabel(label);
entity.setLimits(new HashMap<>(limits));
entity.setGracePeriodDays(gracePeriodDays);
entity.setIssuedAt(now);
entity.setExpiresAt(expiresAt);
entity.setToken(token);
@@ -48,6 +77,17 @@ public class LicenseService {
return saved;
}
/**
* Convenience overload using tier presets and default validity.
*/
public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) {
var limits = LicenseDefaults.limitsForTier(tenant.getTier());
Instant expiresAt = Instant.now().plus(validity);
String label = tenant.getName() + " (" + tenant.getTier().name() + ")";
return generateLicense(tenant, limits, expiresAt,
LicenseDefaults.DEFAULT_GRACE_PERIOD_DAYS, label, actorId);
}
public Optional<LicenseEntity> getActiveLicense(UUID tenantId) {
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
}
@@ -64,19 +104,41 @@ public class LicenseService {
}
/**
* Verifies a license token by checking its existence and validity in the database.
* Returns the license entity's metadata as a map if found and not expired/revoked,
* or empty if the token is unknown or invalid.
* Verify a signed license token using the stored public key.
* Returns the parsed LicenseInfo if valid, or empty if invalid.
*/
public Optional<Map<String, Object>> verifyLicenseToken(String token) {
return licenseRepository.findByToken(token)
.filter(e -> e.getRevokedAt() == null)
.filter(e -> e.getExpiresAt() == null || Instant.now().isBefore(e.getExpiresAt()))
.map(e -> Map.<String, Object>of(
"tenant_id", e.getTenantId().toString(),
"tier", e.getTier(),
"features", e.getFeatures(),
"limits", e.getLimits()
));
public Optional<LicenseInfo> verifyToken(String token, String expectedTenantId) {
try {
String publicKeyB64 = signingKeyService.getPublicKeyBase64();
LicenseValidator validator = new LicenseValidator(publicKeyB64, expectedTenantId);
LicenseInfo info = validator.validate(token);
return Optional.of(info);
} catch (Exception e) {
log.debug("License token verification failed: {}", e.getMessage());
return Optional.empty();
}
}
/**
* Verify a signed license token without tenant ID check (for vendor verify tool).
* Decodes the payload and validates the signature only.
*/
public Optional<LicenseInfo> verifyTokenSignature(String token) {
try {
// Decode the payload portion to extract tenantId, then validate
String payloadB64 = token.split("\\.", 2)[0];
String payloadJson = new String(java.util.Base64.getDecoder().decode(payloadB64));
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var tree = mapper.readTree(payloadJson);
String tenantId = tree.has("tid") ? tree.get("tid").asText() : tree.get("tenantId").asText();
String publicKeyB64 = signingKeyService.getPublicKeyBase64();
LicenseValidator validator = new LicenseValidator(publicKeyB64, tenantId);
LicenseInfo info = validator.validate(token);
return Optional.of(info);
} catch (Exception e) {
log.debug("License token signature verification failed: {}", e.getMessage());
return Optional.empty();
}
}
}

View File

@@ -0,0 +1,47 @@
package net.siegeln.cameleer.saas.license;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "signing_keys")
public class SigningKeyEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "public_key_b64", nullable = false, columnDefinition = "text")
private String publicKeyB64;
@Column(name = "private_key_b64", nullable = false, columnDefinition = "text")
private String privateKeyB64;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = Instant.now();
}
public UUID getId() { return id; }
public String getPublicKeyB64() { return publicKeyB64; }
public void setPublicKeyB64(String publicKeyB64) { this.publicKeyB64 = publicKeyB64; }
public String getPrivateKeyB64() { return privateKeyB64; }
public void setPrivateKeyB64(String privateKeyB64) { this.privateKeyB64 = privateKeyB64; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -0,0 +1,11 @@
package net.siegeln.cameleer.saas.license;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface SigningKeyRepository extends JpaRepository<SigningKeyEntity, UUID> {
Optional<SigningKeyEntity> findByActiveTrue();
}

View File

@@ -0,0 +1,88 @@
package net.siegeln.cameleer.saas.license;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
@Service
public class SigningKeyService {
private static final Logger log = LoggerFactory.getLogger(SigningKeyService.class);
private final SigningKeyRepository signingKeyRepository;
public SigningKeyService(SigningKeyRepository signingKeyRepository) {
this.signingKeyRepository = signingKeyRepository;
}
/**
* Returns the active signing key, generating a new Ed25519 keypair on first call.
*/
public SigningKeyEntity getOrCreateActiveKey() {
return signingKeyRepository.findByActiveTrue()
.orElseGet(this::generateAndStoreKey);
}
/**
* Returns the base64-encoded public key (X.509 SPKI format).
*/
public String getPublicKeyBase64() {
return getOrCreateActiveKey().getPublicKeyB64();
}
/**
* Reconstructs the Ed25519 PrivateKey from the stored base64.
*/
public PrivateKey getPrivateKey() {
SigningKeyEntity key = getOrCreateActiveKey();
try {
byte[] keyBytes = Base64.getDecoder().decode(key.getPrivateKeyB64());
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
} catch (Exception e) {
throw new IllegalStateException("Failed to reconstruct Ed25519 private key", e);
}
}
/**
* Reconstructs the Ed25519 PublicKey from the stored base64.
*/
public PublicKey getPublicKey() {
SigningKeyEntity key = getOrCreateActiveKey();
try {
byte[] keyBytes = Base64.getDecoder().decode(key.getPublicKeyB64());
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
return KeyFactory.getInstance("Ed25519").generatePublic(spec);
} catch (Exception e) {
throw new IllegalStateException("Failed to reconstruct Ed25519 public key", e);
}
}
private SigningKeyEntity generateAndStoreKey() {
try {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
var entity = new SigningKeyEntity();
entity.setPublicKeyB64(pubB64);
entity.setPrivateKeyB64(privB64);
entity.setActive(true);
var saved = signingKeyRepository.save(entity);
log.info("Generated new Ed25519 signing keypair (id={})", saved.getId());
return saved;
} catch (Exception e) {
throw new IllegalStateException("Failed to generate Ed25519 keypair", e);
}
}
}

View File

@@ -0,0 +1,32 @@
package net.siegeln.cameleer.saas.license.dto;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
public record LicenseBundleResponse(
UUID id,
UUID tenantId,
String tenantSlug,
String tier,
String label,
Map<String, Object> limits,
int gracePeriodDays,
Instant issuedAt,
Instant expiresAt,
String token,
String publicKeyB64,
boolean pushedToServer
) {
public static LicenseBundleResponse from(LicenseEntity e, String tenantSlug,
String publicKeyB64, boolean pushed) {
return new LicenseBundleResponse(
e.getId(), e.getTenantId(), tenantSlug, e.getTier(),
e.getLabel(), e.getLimits(), e.getGracePeriodDays(),
e.getIssuedAt(), e.getExpiresAt(), e.getToken(),
publicKeyB64, pushed
);
}
}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.license.dto;
import java.util.Map;
public record LicensePreset(String tier, Map<String, Integer> limits) {}

View File

@@ -10,8 +10,9 @@ public record LicenseResponse(
UUID id,
UUID tenantId,
String tier,
Map<String, Object> features,
String label,
Map<String, Object> limits,
int gracePeriodDays,
Instant issuedAt,
Instant expiresAt,
String token
@@ -19,7 +20,7 @@ public record LicenseResponse(
public static LicenseResponse from(LicenseEntity e) {
return new LicenseResponse(
e.getId(), e.getTenantId(), e.getTier(),
e.getFeatures(), e.getLimits(),
e.getLabel(), e.getLimits(), e.getGracePeriodDays(),
e.getIssuedAt(), e.getExpiresAt(),
e.getToken()
);

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.license.dto;
import java.time.Instant;
import java.util.Map;
public record MintLicenseRequest(
String tier,
Map<String, Integer> limits,
Instant expiresAt,
Integer gracePeriodDays,
String label,
boolean pushToServer
) {}

View File

@@ -0,0 +1,3 @@
package net.siegeln.cameleer.saas.license.dto;
public record VerifyLicenseRequest(String token) {}

View File

@@ -0,0 +1,20 @@
package net.siegeln.cameleer.saas.license.dto;
import java.time.Instant;
import java.util.Map;
public record VerifyLicenseResponse(
boolean valid,
String state,
String tenantId,
String label,
Map<String, Integer> limits,
Instant issuedAt,
Instant expiresAt,
int gracePeriodDays,
String error
) {
public static VerifyLicenseResponse invalid(String error) {
return new VerifyLicenseResponse(false, "INVALID", null, null, null, null, null, 0, error);
}
}

View File

@@ -0,0 +1,69 @@
package net.siegeln.cameleer.saas.notification;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@RestController
@RequestMapping("/api/password-reset-notification")
public class PasswordResetNotificationController {
private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationController.class);
private static final long WINDOW_MS = 10 * 60 * 1000L; // 10 minutes
private static final int MAX_PER_WINDOW = 3;
private final PasswordResetNotificationService notificationService;
// email -> [windowStart, count]
private final ConcurrentHashMap<String, long[]> rateLimitMap = new ConcurrentHashMap<>();
public PasswordResetNotificationController(PasswordResetNotificationService notificationService) {
this.notificationService = notificationService;
}
public record NotificationRequest(String email) {}
@PostMapping
public ResponseEntity<Map<String, Object>> sendNotification(@RequestBody NotificationRequest request) {
String email = request.email();
if (email == null || !email.contains("@")) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Invalid email address"));
}
if (isRateLimited(email)) {
log.warn("Rate limit exceeded for password-reset notification to {}", email);
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", "Too many requests — please wait before retrying"));
}
// Fire-and-forget: send asynchronously to avoid blocking the sign-in flow
Thread.ofVirtual().start(() -> notificationService.sendNotification(email));
return ResponseEntity.ok(Map.of("sent", true));
}
private boolean isRateLimited(String email) {
long now = System.currentTimeMillis();
var entry = rateLimitMap.compute(email, (key, existing) -> {
if (existing == null || now - existing[0] >= WINDOW_MS) {
// New window
return new long[]{now, 1};
}
// Same window — increment
existing[1]++;
return existing;
});
return entry[1] > MAX_PER_WINDOW;
}
}

View File

@@ -0,0 +1,125 @@
package net.siegeln.cameleer.saas.notification;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import net.siegeln.cameleer.saas.vendor.EmailConnectorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Properties;
@Service
public class PasswordResetNotificationService {
private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationService.class);
private static final DateTimeFormatter TIMESTAMP_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'");
private final EmailConnectorService emailConnectorService;
private final LogtoManagementClient logtoClient;
private final ProvisioningProperties provisioningProps;
public PasswordResetNotificationService(EmailConnectorService emailConnectorService,
LogtoManagementClient logtoClient,
ProvisioningProperties provisioningProps) {
this.emailConnectorService = emailConnectorService;
this.logtoClient = logtoClient;
this.provisioningProps = provisioningProps;
}
/**
* Sends a password-reset security notification to the given email address.
* Fire-and-forget: logs a warning on failure but does not throw.
*/
public void sendNotification(String toEmail) {
try {
doSend(toEmail);
} catch (Exception e) {
log.warn("Failed to send password-reset notification to {}: {}", toEmail, e.getMessage());
}
}
@SuppressWarnings("unchecked")
private void doSend(String toEmail) throws Exception {
// Read the full connector config from Logto (includes password)
var connectorStatus = emailConnectorService.getEmailConnector();
if (connectorStatus == null) {
log.warn("No email connector configured — skipping password-reset notification for {}", toEmail);
return;
}
// Re-read the raw connector config to get the password (getEmailConnector() omits it)
var connectors = logtoClient.listConnectors();
var raw = connectors.stream()
.filter(c -> "Email".equals(c.get("type")))
.findFirst()
.orElse(null);
if (raw == null) {
log.warn("Email connector not found in raw list — skipping notification for {}", toEmail);
return;
}
var config = (Map<String, Object>) raw.getOrDefault("config", Map.of());
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
String host = connectorStatus.host();
int port = connectorStatus.port();
String username = connectorStatus.username();
String fromEmail = connectorStatus.fromEmail();
String password = String.valueOf(auth.getOrDefault("pass", ""));
String htmlBody = buildHtmlBody();
// Build a programmatic JavaMailSender from the runtime SMTP config
var sender = new JavaMailSenderImpl();
sender.setHost(host);
sender.setPort(port);
sender.setUsername(username);
sender.setPassword(password);
sender.setDefaultEncoding("UTF-8");
Properties props = sender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", "true");
if (port == 465) {
props.put("mail.smtp.ssl.enable", "true");
} else {
props.put("mail.smtp.starttls.enable", "true");
}
props.put("mail.smtp.timeout", "10000");
props.put("mail.smtp.connectiontimeout", "10000");
var mimeMessage = sender.createMimeMessage();
var helper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
helper.setTo(toEmail);
helper.setFrom(fromEmail);
helper.setSubject("Your Cameleer password was reset");
helper.setText(htmlBody, true);
sender.send(mimeMessage);
log.info("Password-reset notification sent to {}", toEmail);
}
private String buildHtmlBody() throws IOException {
String content = new ClassPathResource("email-templates/password-reset-notification.html")
.getContentAsString(StandardCharsets.UTF_8);
String watermarkUrl = provisioningProps.publicProtocol() + "://"
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
String timestamp = ZonedDateTime.now(ZoneOffset.UTC).format(TIMESTAMP_FMT);
return content
.replace("{{watermarkUrl}}", watermarkUrl)
.replace("{{timestamp}}", timestamp);
}
}

View File

@@ -0,0 +1,66 @@
package net.siegeln.cameleer.saas.onboarding;
import jakarta.validation.Valid;
import java.util.Map;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/onboarding")
public class OnboardingController {
private final OnboardingService onboardingService;
private final TenantRepository tenantRepository;
public OnboardingController(OnboardingService onboardingService, TenantRepository tenantRepository) {
this.onboardingService = onboardingService;
this.tenantRepository = tenantRepository;
}
public record CreateTrialTenantRequest(
@jakarta.validation.constraints.NotBlank
@jakarta.validation.constraints.Size(max = 255)
String name,
@jakarta.validation.constraints.NotBlank
@jakarta.validation.constraints.Size(max = 100)
@jakarta.validation.constraints.Pattern(
regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
message = "Slug must be lowercase alphanumeric with hyphens")
String slug
) {}
@GetMapping("/slug-available")
public ResponseEntity<Map<String, Boolean>> slugAvailable(@RequestParam String slug) {
boolean taken = tenantRepository.existsBySlugAndStatusNot(slug, TenantStatus.DELETED);
return ResponseEntity.ok(Map.of("available", !taken));
}
@PostMapping("/tenant")
public ResponseEntity<TenantResponse> createTrialTenant(
@Valid @RequestBody CreateTrialTenantRequest request,
@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
try {
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(null);
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(null);
}
}
}

View File

@@ -0,0 +1,85 @@
package net.siegeln.cameleer.saas.onboarding;
import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import net.siegeln.cameleer.saas.vendor.VendorTenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* Self-service onboarding: lets a newly registered user create their own trial tenant.
* Reuses VendorTenantService for the heavy lifting (Logto org, license, Docker provisioning)
* but adds the calling user as the tenant owner instead of creating a new admin user.
*/
@Service
public class OnboardingService {
private static final Logger log = LoggerFactory.getLogger(OnboardingService.class);
private final VendorTenantService vendorTenantService;
private final LogtoManagementClient logtoClient;
private final AccountService accountService;
public OnboardingService(VendorTenantService vendorTenantService,
LogtoManagementClient logtoClient,
AccountService accountService) {
this.vendorTenantService = vendorTenantService;
this.logtoClient = logtoClient;
this.accountService = accountService;
}
public TenantEntity createTrialTenant(String name, String slug, String logtoUserId) {
// Guard: check if user already has a tenant (prevent abuse)
if (logtoClient.isAvailable()) {
var orgs = logtoClient.getUserOrganizations(logtoUserId);
if (!orgs.isEmpty()) {
throw new IllegalStateException("You already have a tenant. Only one trial tenant per account.");
}
}
// Create tenant via the existing vendor flow (no admin user — we'll add the caller)
UUID actorId = resolveActorId(logtoUserId);
var request = new CreateTenantRequest(name, slug, "STARTER", null, null);
TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId);
// Add the calling user to the Logto org as owner
if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) {
try {
String ownerRoleId = logtoClient.findOrgRoleIdByName("owner");
logtoClient.addUserToOrganization(tenant.getLogtoOrgId(), logtoUserId);
if (ownerRoleId != null) {
logtoClient.assignOrganizationRole(tenant.getLogtoOrgId(), logtoUserId, ownerRoleId);
}
log.info("Added user {} as owner of tenant {}", logtoUserId, slug);
// Set display name from email if not already set (email-registered users have no name)
var profile = accountService.getProfile(logtoUserId);
if (profile.name() == null || profile.name().isBlank()) {
String email = profile.email();
if (!email.isBlank() && email.contains("@")) {
String displayName = email.substring(0, email.indexOf('@'));
accountService.updateDisplayName(logtoUserId, displayName);
log.info("Set display name '{}' for user {}", displayName, logtoUserId);
}
}
} catch (Exception e) {
log.warn("Failed to add user {} to org for tenant {}: {}", logtoUserId, slug, e.getMessage());
}
}
return tenant;
}
private UUID resolveActorId(String subject) {
try {
return UUID.fromString(subject);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(subject.getBytes());
}
}
}

View File

@@ -3,7 +3,9 @@ package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.certificate.TenantCaCertEntity;
import net.siegeln.cameleer.saas.certificate.TenantCaCertService;
import net.siegeln.cameleer.saas.config.TenantContext;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -21,6 +23,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@RestController
@@ -29,10 +32,14 @@ public class TenantPortalController {
private final TenantPortalService portalService;
private final TenantCaCertService caCertService;
private final TenantService tenantService;
public TenantPortalController(TenantPortalService portalService, TenantCaCertService caCertService) {
public TenantPortalController(TenantPortalService portalService,
TenantCaCertService caCertService,
TenantService tenantService) {
this.portalService = portalService;
this.caCertService = caCertService;
this.tenantService = tenantService;
}
// --- Request bodies ---
@@ -43,6 +50,8 @@ public class TenantPortalController {
public record PasswordChangeRequest(String password) {}
public record TotpVerifyRequest(String secret, String code) {}
// --- Endpoints ---
@GetMapping("/dashboard")
@@ -98,12 +107,8 @@ public class TenantPortalController {
@PostMapping("/password")
public ResponseEntity<Void> changeOwnPassword(@AuthenticationPrincipal Jwt jwt,
@RequestBody PasswordChangeRequest body) {
try {
portalService.changePassword(jwt.getSubject(), body.password());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/team/{userId}/password")
@@ -134,6 +139,131 @@ public class TenantPortalController {
return ResponseEntity.ok(portalService.getSettings());
}
// --- MFA endpoints ---
@GetMapping("/mfa/status")
public ResponseEntity<TenantPortalService.MfaStatusData> getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(portalService.getMfaStatus(jwt.getSubject()));
}
@PostMapping("/mfa/totp/setup")
public ResponseEntity<TenantPortalService.MfaSetupData> setupTotp(@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(portalService.setupTotp(jwt.getSubject()));
}
@PostMapping("/mfa/totp/verify")
public ResponseEntity<?> verifyTotp(@AuthenticationPrincipal Jwt jwt,
@RequestBody TotpVerifyRequest request) {
boolean valid = portalService.verifyTotpCode(request.secret(), request.code());
if (!valid) {
return ResponseEntity.unprocessableEntity().body(Map.of("verified", false));
}
return ResponseEntity.ok(Map.of("verified", true));
}
@PostMapping("/mfa/backup-codes")
public ResponseEntity<TenantPortalService.BackupCodesData> generateBackupCodes(
@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(portalService.generateBackupCodes(jwt.getSubject()));
}
@DeleteMapping("/mfa/totp")
public ResponseEntity<Void> removeTotp(@AuthenticationPrincipal Jwt jwt) {
portalService.removeTotp(jwt.getSubject());
return ResponseEntity.noContent().build();
}
@DeleteMapping("/users/{userId}/mfa")
public ResponseEntity<Void> resetTeamMemberMfa(@PathVariable String userId) {
try {
portalService.resetTeamMemberMfa(userId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
// --- Passkey endpoints ---
@GetMapping("/mfa/webauthn")
public ResponseEntity<List<TenantPortalService.PasskeyCredential>> listPasskeys(
@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(portalService.listPasskeys(jwt.getSubject()));
}
@PatchMapping("/mfa/webauthn/{id}/name")
public ResponseEntity<Void> renamePasskey(@AuthenticationPrincipal Jwt jwt,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String name = body.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().build();
}
portalService.renamePasskey(jwt.getSubject(), id, name);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/mfa/webauthn/{id}")
public ResponseEntity<Void> deletePasskey(@AuthenticationPrincipal Jwt jwt,
@PathVariable String id) {
portalService.deletePasskey(jwt.getSubject(), id);
return ResponseEntity.noContent().build();
}
@PostMapping("/mfa/method-preference")
public ResponseEntity<Void> updateMfaMethodPreference(@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, String> body) {
String preference = body.get("preference");
if (preference == null || !Set.of("totp", "webauthn").contains(preference)) {
return ResponseEntity.badRequest().build();
}
portalService.updateMfaMethodPreference(jwt.getSubject(), preference);
return ResponseEntity.noContent().build();
}
// --- Auth settings endpoints ---
@GetMapping("/auth-settings")
public ResponseEntity<TenantPortalService.AuthSettingsData> getAuthSettings() {
return ResponseEntity.ok(portalService.getAuthSettings());
}
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PatchMapping("/auth-settings")
public ResponseEntity<Void> updateAuthSettings(@RequestBody Map<String, Object> updates) {
portalService.updateTenantSettings(updates);
return ResponseEntity.ok().build();
}
@PatchMapping("/settings")
public ResponseEntity<Void> updateSettings(@RequestBody Map<String, Object> updates) {
portalService.updateTenantSettings(updates);
return ResponseEntity.ok().build();
}
@GetMapping("/{slug}/mfa-policy")
public ResponseEntity<Map<String, Object>> getMfaPolicy(@PathVariable String slug) {
var tenantOpt = tenantService.getBySlug(slug);
if (tenantOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
var tenant = tenantOpt.get();
Map<String, Object> settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of();
String mfaMode = settings.containsKey("mfaMode")
? String.valueOf(settings.get("mfaMode"))
: (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off");
boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled"));
String passkeyMode = settings.containsKey("passkeyMode")
? String.valueOf(settings.get("passkeyMode"))
: "optional";
return ResponseEntity.ok(Map.of(
"mfaRequired", "required".equals(mfaMode),
"mfaMode", mfaMode,
"passkeyEnabled", passkeyEnabled,
"passkeyMode", passkeyMode
));
}
// --- CA Certificate management ---
public record CaCertResponse(

View File

@@ -1,5 +1,6 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.config.TenantContext;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient;
@@ -17,8 +18,10 @@ import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Service
@@ -33,6 +36,7 @@ public class TenantPortalService {
private final TenantProvisioner tenantProvisioner;
private final ProvisioningProperties provisioningProps;
private final VendorTenantService vendorTenantService;
private final AccountService accountService;
public TenantPortalService(TenantService tenantService,
LicenseService licenseService,
@@ -40,7 +44,8 @@ public class TenantPortalService {
LogtoManagementClient logtoClient,
TenantProvisioner tenantProvisioner,
ProvisioningProperties provisioningProps,
@Lazy VendorTenantService vendorTenantService) {
@Lazy VendorTenantService vendorTenantService,
AccountService accountService) {
this.tenantService = tenantService;
this.licenseService = licenseService;
this.serverApiClient = serverApiClient;
@@ -48,6 +53,7 @@ public class TenantPortalService {
this.tenantProvisioner = tenantProvisioner;
this.provisioningProps = provisioningProps;
this.vendorTenantService = vendorTenantService;
this.accountService = accountService;
}
// --- Inner records ---
@@ -56,13 +62,15 @@ public class TenantPortalService {
String name, String slug, String tier, String status,
boolean serverHealthy, String serverStatus, String serverEndpoint,
String licenseTier, long licenseDaysRemaining,
Map<String, Object> limits, Map<String, Object> features,
Map<String, Object> limits,
int agentCount, int environmentCount
) {}
public record LicenseData(
UUID id, String tier, Map<String, Object> features, Map<String, Object> limits,
Instant issuedAt, Instant expiresAt, String token, long daysRemaining
UUID id, String tier, String label, Map<String, Object> limits,
int gracePeriodDays, Instant issuedAt, Instant expiresAt,
String token, long daysRemaining,
Map<String, Integer> usage
) {}
public record TenantSettingsData(
@@ -70,6 +78,12 @@ public class TenantPortalService {
String serverEndpoint, Instant createdAt
) {}
public record MfaStatusData(boolean enrolled, boolean hasBackupCodes, boolean passkeyEnrolled, int passkeyCount) {}
public record MfaSetupData(String secret, String secretQrCode) {}
public record BackupCodesData(List<String> codes) {}
// --- Helpers ---
private TenantEntity resolveTenant() {
@@ -107,7 +121,6 @@ public class TenantPortalService {
String licenseTier = null;
long licenseDaysRemaining = 0;
Map<String, Object> limits = Map.of();
Map<String, Object> features = Map.of();
var licenseOpt = licenseService.getActiveLicense(tenant.getId());
if (licenseOpt.isPresent()) {
@@ -115,7 +128,6 @@ public class TenantPortalService {
licenseTier = lic.getTier();
licenseDaysRemaining = daysUntil(lic.getExpiresAt());
limits = lic.getLimits() != null ? lic.getLimits() : Map.of();
features = lic.getFeatures() != null ? lic.getFeatures() : Map.of();
}
return new DashboardData(
@@ -123,23 +135,43 @@ public class TenantPortalService {
tenant.getTier().name(), tenant.getStatus().name(),
serverHealthy, serverStatus, endpoint,
licenseTier, licenseDaysRemaining,
limits, features, agentCount, environmentCount
limits, agentCount, environmentCount
);
}
public LicenseData getLicense() {
TenantEntity tenant = resolveTenant();
return licenseService.getActiveLicense(tenant.getId())
.map(lic -> new LicenseData(
lic.getId(), lic.getTier(),
lic.getFeatures() != null ? lic.getFeatures() : Map.of(),
.map(lic -> {
Map<String, Integer> usage = fetchUsage(tenant);
return new LicenseData(
lic.getId(), lic.getTier(), lic.getLabel(),
lic.getLimits() != null ? lic.getLimits() : Map.of(),
lic.getGracePeriodDays(),
lic.getIssuedAt(), lic.getExpiresAt(),
lic.getToken(), daysUntil(lic.getExpiresAt())
))
lic.getToken(), daysUntil(lic.getExpiresAt()),
usage
);
})
.orElse(null);
}
private Map<String, Integer> fetchUsage(TenantEntity tenant) {
Map<String, Integer> usage = new HashMap<>();
String endpoint = tenant.getServerEndpoint();
if (endpoint != null && !endpoint.isBlank()) {
usage.put("agents", serverApiClient.getAgentCount(endpoint));
usage.put("environments", serverApiClient.getEnvironmentCount(endpoint));
usage.put("apps", serverApiClient.getAppCount(endpoint));
}
// User count from Logto org membership
String orgId = tenant.getLogtoOrgId();
if (orgId != null && !orgId.isBlank()) {
usage.put("users", logtoClient.listOrganizationMembers(orgId).size());
}
return usage;
}
public List<Map<String, Object>> listTeamMembers() {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
@@ -149,13 +181,14 @@ public class TenantPortalService {
return logtoClient.listOrganizationMembers(orgId);
}
public String inviteTeamMember(String email, String roleId) {
public String inviteTeamMember(String email, String roleName) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
return logtoClient.createAndInviteUser(email, orgId, roleId);
String resolvedRoleId = resolveOrgRoleId(roleName);
return logtoClient.createAndInviteUser(email, orgId, resolvedRoleId);
}
public void removeTeamMember(String userId) {
@@ -165,15 +198,33 @@ public class TenantPortalService {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
logtoClient.removeUserFromOrganization(orgId, userId);
// If the user has no remaining org memberships, delete from Logto entirely
var remainingOrgs = logtoClient.getUserOrganizations(userId);
if (remainingOrgs.isEmpty()) {
log.info("User {} has no remaining org memberships — deleting from Logto", userId);
logtoClient.deleteUser(userId);
}
}
public void changeTeamMemberRole(String userId, String roleId) {
public void changeTeamMemberRole(String userId, String roleName) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
logtoClient.assignOrganizationRole(orgId, userId, roleId);
String resolvedRoleId = resolveOrgRoleId(roleName);
logtoClient.assignOrganizationRole(orgId, userId, resolvedRoleId);
}
/** Resolve a role name (e.g. "viewer") to a Logto organization role ID. */
private String resolveOrgRoleId(String roleName) {
if (roleName == null || roleName.isBlank()) return null;
String resolved = logtoClient.findOrgRoleIdByName(roleName);
if (resolved == null) {
throw new IllegalArgumentException("Unknown organization role: " + roleName);
}
return resolved;
}
public void resetServerAdminPassword(String newPassword) {
@@ -189,9 +240,7 @@ public class TenantPortalService {
}
public void changePassword(String userId, String newPassword) {
if (newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
accountService.validatePassword(newPassword);
logtoClient.updateUserPassword(userId, newPassword);
}
@@ -257,4 +306,109 @@ public class TenantPortalService {
vendorTenantService.provisionAsync(
tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null);
}
// --- MFA methods ---
public MfaStatusData getMfaStatus(String userId) {
var data = accountService.getMfaStatus(userId);
return new MfaStatusData(data.enrolled(), data.hasBackupCodes(), data.passkeyEnrolled(), data.passkeyCount());
}
public MfaSetupData setupTotp(String userId) {
var data = accountService.setupTotp(userId);
return new MfaSetupData(data.secret(), data.secretQrCode());
}
public boolean verifyTotpCode(String secret, String code) {
return accountService.verifyTotpCode(secret, code);
}
public BackupCodesData generateBackupCodes(String userId) {
var data = accountService.generateBackupCodes(userId);
return new BackupCodesData(data.codes());
}
public void removeTotp(String userId) {
accountService.removeMfa(userId);
}
public void resetTeamMemberMfa(String userId) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
// Verify the target user belongs to this tenant's org
var members = logtoClient.listOrganizationMembers(orgId);
boolean isMember = members.stream()
.anyMatch(m -> userId.equals(String.valueOf(m.get("id"))));
if (!isMember) {
throw new IllegalArgumentException("User is not a member of this organization");
}
logtoClient.deleteAllMfaVerifications(userId);
}
// --- Passkey methods ---
public record PasskeyCredential(String id, String name, String agent, String createdAt) {}
public List<PasskeyCredential> listPasskeys(String userId) {
return accountService.listPasskeys(userId).stream()
.map(p -> new PasskeyCredential(p.id(), p.name(), p.agent(), p.createdAt()))
.toList();
}
public void renamePasskey(String userId, String credentialId, String name) {
accountService.renamePasskey(userId, credentialId, name);
}
public void deletePasskey(String userId, String credentialId) {
accountService.deletePasskey(userId, credentialId);
}
public void updateMfaMethodPreference(String userId, String preference) {
accountService.setMfaMethodPreference(userId, preference);
}
public void updateTenantSettings(Map<String, Object> updates) {
TenantEntity tenant = resolveTenant();
Map<String, Object> settings = new HashMap<>(
tenant.getSettings() != null ? tenant.getSettings() : Map.of());
if (updates.containsKey("mfaRequired")) {
settings.put("mfaRequired", Boolean.TRUE.equals(updates.get("mfaRequired")));
}
if (updates.containsKey("mfaMode")) {
String mode = String.valueOf(updates.get("mfaMode"));
if (Set.of("off", "optional", "required").contains(mode)) {
settings.put("mfaMode", mode);
}
}
if (updates.containsKey("passkeyEnabled")) {
settings.put("passkeyEnabled", Boolean.TRUE.equals(updates.get("passkeyEnabled")));
}
if (updates.containsKey("passkeyMode")) {
String mode = String.valueOf(updates.get("passkeyMode"));
if (Set.of("optional", "preferred", "required").contains(mode)) {
settings.put("passkeyMode", mode);
}
}
tenant.setSettings(settings);
tenantService.save(tenant);
}
public record AuthSettingsData(String mfaMode, boolean passkeyEnabled, String passkeyMode) {}
public AuthSettingsData getAuthSettings() {
TenantEntity tenant = resolveTenant();
Map<String, Object> settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of();
String mfaMode = settings.containsKey("mfaMode")
? String.valueOf(settings.get("mfaMode"))
: (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off");
boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled"));
String passkeyMode = settings.containsKey("passkeyMode")
? String.valueOf(settings.get("passkeyMode"))
: "optional";
return new AuthSettingsData(mfaMode, passkeyEnabled, passkeyMode);
}
}

View File

@@ -21,9 +21,12 @@ public class DockerTenantProvisioner implements TenantProvisioner {
private final DockerClient docker;
private final ProvisioningProperties props;
private final net.siegeln.cameleer.saas.license.SigningKeyService signingKeyService;
public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props) {
public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props,
net.siegeln.cameleer.saas.license.SigningKeyService signingKeyService) {
this.props = props;
this.signingKeyService = signingKeyService;
DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder()
.dockerHost(config.getDockerHost())
.maxConnections(10)
@@ -223,6 +226,7 @@ public class DockerTenantProvisioner implements TenantProvisioner {
"CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE=https://api.cameleer.local",
"CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS=" + props.corsOrigins(),
"CAMELEER_SERVER_LICENSE_TOKEN=" + req.licenseToken(),
"CAMELEER_SERVER_LICENSE_PUBLICKEY=" + signingKeyService.getPublicKeyBase64(),
"CAMELEER_SERVER_RUNTIME_ENABLED=true",
"CAMELEER_SERVER_RUNTIME_SERVERURL=http://" + name + ":8081",
"CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN=" + props.publicHost(),

View File

@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.provisioning;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientConfig;
import net.siegeln.cameleer.saas.license.SigningKeyService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -17,13 +18,13 @@ public class TenantProvisionerAutoConfig {
private static final Logger log = LoggerFactory.getLogger(TenantProvisionerAutoConfig.class);
@Bean
TenantProvisioner tenantProvisioner(ProvisioningProperties props) {
TenantProvisioner tenantProvisioner(ProvisioningProperties props, SigningKeyService signingKeyService) {
if (Files.exists(Path.of("/var/run/docker.sock"))) {
log.info("Docker socket detected — enabling Docker tenant provisioner");
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
.withDockerHost("unix:///var/run/docker.sock")
.build();
return new DockerTenantProvisioner(config, props);
return new DockerTenantProvisioner(config, props, signingKeyService);
}
log.info("No Docker socket — tenant provisioning disabled");
return new DisabledTenantProvisioner();

View File

@@ -33,7 +33,7 @@ public class TenantEntity {
@Enumerated(EnumType.STRING)
@Column(name = "tier", nullable = false, length = 20)
private Tier tier = Tier.LOW;
private Tier tier = Tier.STARTER;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)

View File

@@ -31,7 +31,7 @@ public class TenantService {
var entity = new TenantEntity();
entity.setName(request.name());
entity.setSlug(request.slug());
entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.LOW);
entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.STARTER);
entity.setStatus(TenantStatus.PROVISIONING);
var saved = tenantRepository.save(entity);
@@ -84,6 +84,10 @@ public class TenantService {
return saved;
}
public TenantEntity save(TenantEntity entity) {
return tenantRepository.save(entity);
}
public TenantEntity suspend(UUID tenantId, UUID actorId) {
var entity = tenantRepository.findById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));

View File

@@ -1,5 +1,5 @@
package net.siegeln.cameleer.saas.tenant;
public enum Tier {
LOW, MID, HIGH, BUSINESS
STARTER, TEAM, BUSINESS, ENTERPRISE
}

View File

@@ -0,0 +1,131 @@
package net.siegeln.cameleer.saas.vendor;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/vendor/email-connector")
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public class EmailConnectorController {
private final EmailConnectorService emailConnectorService;
public EmailConnectorController(EmailConnectorService emailConnectorService) {
this.emailConnectorService = emailConnectorService;
}
// --- Request/Response types ---
public record SmtpConfigRequest(
@NotBlank String host,
@Min(1) @Max(65535) int port,
@NotBlank String username,
String password,
@NotBlank @Email String fromEmail,
Boolean registrationEnabled
) {}
public record TestEmailRequest(
@NotBlank @Email String to
) {}
public record EmailConnectorResponse(
String connectorId,
String factoryId,
String host,
int port,
String username,
String fromEmail,
boolean registrationEnabled
) {
static EmailConnectorResponse from(EmailConnectorService.EmailConnectorStatus status) {
return new EmailConnectorResponse(
status.connectorId(),
status.factoryId(),
status.host(),
status.port(),
status.username(),
status.fromEmail(),
status.registrationEnabled()
);
}
}
// --- Endpoints ---
@GetMapping
public ResponseEntity<EmailConnectorResponse> get() {
var status = emailConnectorService.getEmailConnector();
if (status == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(EmailConnectorResponse.from(status));
}
@PostMapping
public ResponseEntity<?> save(@Valid @RequestBody SmtpConfigRequest request) {
// Resolve password: use provided value, or fall back to existing password from Logto
String password = request.password();
if (password == null || password.isBlank()) {
password = emailConnectorService.getExistingPassword();
if (password == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Password is required for new configuration"));
}
}
var smtp = new EmailConnectorService.SmtpConfig(
request.host(), request.port(), request.username(),
password, request.fromEmail()
);
// Test SMTP connection before saving
try {
emailConnectorService.testSmtpConnection(smtp);
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
var status = emailConnectorService.saveSmtpConnector(smtp, request.registrationEnabled());
return ResponseEntity.ok(EmailConnectorResponse.from(status));
}
@DeleteMapping
public ResponseEntity<Void> delete() {
emailConnectorService.deleteEmailConnector();
return ResponseEntity.noContent().build();
}
@PostMapping("/test")
public ResponseEntity<Map<String, String>> test(@Valid @RequestBody TestEmailRequest request) {
try {
emailConnectorService.sendTestEmail(request.to());
return ResponseEntity.ok(Map.of("status", "sent", "message", "Test email sent to " + request.to()));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("status", "failed", "message", e.getMessage()));
}
}
@PostMapping("/registration")
public ResponseEntity<Void> toggleRegistration(@RequestBody Map<String, Boolean> body) {
boolean enabled = body.getOrDefault("enabled", false);
var existing = emailConnectorService.getEmailConnector();
if (existing == null && enabled) {
return ResponseEntity.badRequest().build();
}
emailConnectorService.setRegistrationEnabled(enabled);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,262 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
@Service
public class EmailConnectorService {
private static final Logger log = LoggerFactory.getLogger(EmailConnectorService.class);
private static final String SMTP_FACTORY_ID = "simple-mail-transfer-protocol";
private final LogtoManagementClient logtoClient;
private final ProvisioningProperties provisioningProps;
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
this.logtoClient = logtoClient;
this.provisioningProps = provisioningProps;
}
public record SmtpConfig(String host, int port, String username, String password, String fromEmail) {}
public record EmailConnectorStatus(
String connectorId,
String factoryId,
String host,
int port,
String username,
String fromEmail,
boolean registrationEnabled
) {}
/** Get the current email connector config, or null if none is configured. */
@SuppressWarnings("unchecked")
public EmailConnectorStatus getEmailConnector() {
var connectors = logtoClient.listConnectors();
var emailConnector = connectors.stream()
.filter(c -> "Email".equals(c.get("type")))
.findFirst()
.orElse(null);
if (emailConnector == null) {
return null;
}
var config = (Map<String, Object>) emailConnector.getOrDefault("config", Map.of());
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
String host = String.valueOf(config.getOrDefault("host", ""));
int port = config.containsKey("port") ? ((Number) config.get("port")).intValue() : 587;
String username = String.valueOf(auth.getOrDefault("user", ""));
String fromEmail = String.valueOf(config.getOrDefault("fromEmail", ""));
boolean registrationEnabled = isRegistrationEnabled();
return new EmailConnectorStatus(
String.valueOf(emailConnector.get("id")),
String.valueOf(emailConnector.get("connectorId")),
host, port, username, fromEmail, registrationEnabled
);
}
/**
* Retrieve the existing SMTP password from Logto, or null if not configured.
* Used to retain the password when the user edits other fields without re-entering it.
*/
@SuppressWarnings("unchecked")
public String getExistingPassword() {
var connectors = logtoClient.listConnectors();
var emailConnector = connectors.stream()
.filter(c -> "Email".equals(c.get("type")))
.findFirst()
.orElse(null);
if (emailConnector == null) return null;
var config = (Map<String, Object>) emailConnector.getOrDefault("config", Map.of());
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
String pass = String.valueOf(auth.getOrDefault("pass", ""));
return pass.isEmpty() ? null : pass;
}
/**
* Test SMTP connection by performing EHLO + auth. Throws on failure.
*/
public void testSmtpConnection(SmtpConfig smtp) {
var sender = new JavaMailSenderImpl();
sender.setHost(smtp.host());
sender.setPort(smtp.port());
sender.setUsername(smtp.username());
sender.setPassword(smtp.password());
sender.setDefaultEncoding("UTF-8");
Properties props = sender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", "true");
if (smtp.port() == 465) {
props.put("mail.smtp.ssl.enable", "true");
} else {
props.put("mail.smtp.starttls.enable", "true");
}
props.put("mail.smtp.timeout", "10000");
props.put("mail.smtp.connectiontimeout", "10000");
try {
sender.testConnection();
log.info("SMTP connection test successful: {}:{}", smtp.host(), smtp.port());
} catch (Exception e) {
log.warn("SMTP connection test failed: {}:{} — {}", smtp.host(), smtp.port(), e.getMessage());
throw new IllegalStateException("SMTP connection failed: " + e.getMessage(), e);
}
}
/** Create or update the SMTP email connector. Returns the connector status. */
public EmailConnectorStatus saveSmtpConnector(SmtpConfig smtp, Boolean registrationEnabled) {
var connectorConfig = buildSmtpConfig(smtp);
// Check if an email connector already exists
var existing = getEmailConnector();
if (existing != null) {
logtoClient.updateConnector(existing.connectorId(), connectorConfig);
log.info("Updated SMTP email connector: {}", existing.connectorId());
} else {
var result = logtoClient.createConnector(SMTP_FACTORY_ID, connectorConfig);
log.info("Created SMTP email connector: {}", result != null ? result.get("id") : "unknown");
}
// Handle registration toggle
boolean enableReg = registrationEnabled != null ? registrationEnabled : (existing == null);
setRegistrationEnabled(enableReg);
return getEmailConnector();
}
/** Delete the email connector and disable registration. */
public void deleteEmailConnector() {
var existing = getEmailConnector();
if (existing != null) {
logtoClient.deleteConnector(existing.connectorId());
setRegistrationEnabled(false);
log.info("Deleted email connector: {}", existing.connectorId());
}
}
/** Send a test email through the configured connector. */
public void sendTestEmail(String toEmail) {
var existing = getEmailConnector();
if (existing == null) {
throw new IllegalStateException("No email connector configured");
}
// Re-read the full config from Logto to pass to the test endpoint
var connectors = logtoClient.listConnectors();
@SuppressWarnings("unchecked")
var emailConnector = connectors.stream()
.filter(c -> "Email".equals(c.get("type")))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Email connector not found"));
@SuppressWarnings("unchecked")
var config = (Map<String, Object>) emailConnector.getOrDefault("config", Map.of());
logtoClient.testConnector(existing.factoryId(), toEmail, config);
}
/** Set registration mode on the Logto sign-in experience. */
public void setRegistrationEnabled(boolean enabled) {
if (enabled) {
logtoClient.updateSignInExperience(Map.of(
"signInMode", "SignInAndRegister",
"signUp", Map.of(
"identifiers", List.of("email"),
"password", true,
"verify", true
),
"signIn", Map.of(
"methods", List.of(
Map.of("identifier", "email", "password", true, "verificationCode", false, "isPasswordPrimary", true),
Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true)
)
)
));
} else {
logtoClient.updateSignInExperience(Map.of(
"signInMode", "SignIn",
"signUp", Map.of(
"identifiers", List.of("username"),
"password", true,
"verify", false
),
"signIn", Map.of(
"methods", List.of(
Map.of("identifier", "email", "password", true, "verificationCode", false, "isPasswordPrimary", true),
Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true)
)
)
));
}
}
/** Check if registration is currently enabled in Logto. */
@SuppressWarnings("unchecked")
private boolean isRegistrationEnabled() {
var signInExp = logtoClient.getSignInExperience();
if (signInExp == null) return false;
return "SignInAndRegister".equals(signInExp.get("signInMode"));
}
/** Load an email template from classpath and resolve the watermark URL placeholder. */
private String loadTemplate(String filename) {
try {
String content = new ClassPathResource("email-templates/" + filename)
.getContentAsString(StandardCharsets.UTF_8);
String watermarkUrl = provisioningProps.publicProtocol() + "://"
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
return content.replace("{{watermarkUrl}}", watermarkUrl);
} catch (IOException e) {
throw new IllegalStateException("Failed to load email template: " + filename, e);
}
}
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
var config = new HashMap<String, Object>();
config.put("host", smtp.host());
config.put("port", smtp.port());
config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password()));
config.put("fromEmail", smtp.fromEmail());
config.put("templates", List.of(
Map.of(
"usageType", "Register",
"contentType", "text/html",
"subject", "Your caravan pass is almost ready",
"content", loadTemplate("register.html")
),
Map.of(
"usageType", "SignIn",
"contentType", "text/html",
"subject", "Your Cameleer sign-in code",
"content", loadTemplate("sign-in.html")
),
Map.of(
"usageType", "ForgotPassword",
"contentType", "text/html",
"subject", "Reset your Cameleer password",
"content", loadTemplate("forgot-password.html")
),
Map.of(
"usageType", "Generic",
"contentType", "text/html",
"subject", "Your Cameleer verification code",
"content", loadTemplate("generic.html")
)
));
return config;
}
}

View File

@@ -224,9 +224,13 @@ public class InfrastructureService {
.put(table, cnt);
}
} catch (Exception e) {
if (e.getMessage() != null && e.getMessage().contains("UNKNOWN_TABLE")) {
log.debug("ClickHouse table '{}' does not exist yet — skipping", table);
} else {
log.error("Failed to query ClickHouse table '{}' for tenant stats: {}", table, e.getMessage(), e);
}
}
}
} catch (Exception e) {
log.error("Failed to get ClickHouse tenant stats: {}", e.getMessage(), e);
throw new RuntimeException("Failed to get ClickHouse tenant stats", e);
@@ -256,10 +260,14 @@ public class InfrastructureService {
result.add(new ChTableStats(table, rs.getLong("cnt")));
}
} catch (Exception e) {
if (e.getMessage() != null && e.getMessage().contains("UNKNOWN_TABLE")) {
log.debug("ClickHouse table '{}' does not exist yet — skipping", table);
} else {
log.error("Failed to query ClickHouse table '{}' for tenant '{}': {}",
table, tenantId, e.getMessage(), e);
}
}
}
} catch (Exception e) {
log.error("Failed to get ClickHouse tenant detail for '{}': {}", tenantId, e.getMessage(), e);
throw new RuntimeException("Failed to get ClickHouse tenant detail for: " + tenantId, e);

View File

@@ -0,0 +1,53 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.vendor.VendorAdminService.CreateAdminRequest;
import net.siegeln.cameleer.saas.vendor.VendorAdminService.CreateAdminResponse;
import net.siegeln.cameleer.saas.vendor.VendorAdminService.VendorAdmin;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vendor/admins")
public class VendorAdminController {
private final VendorAdminService vendorAdminService;
public VendorAdminController(VendorAdminService vendorAdminService) {
this.vendorAdminService = vendorAdminService;
}
@GetMapping
public List<VendorAdmin> listAdmins() {
return vendorAdminService.listAdmins();
}
@PostMapping
public CreateAdminResponse createAdmin(@RequestBody CreateAdminRequest request) {
return vendorAdminService.createAdmin(request);
}
@DeleteMapping("/{userId}")
public ResponseEntity<Void> removeAdmin(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId) {
vendorAdminService.removeAdmin(userId, jwt.getSubject());
return ResponseEntity.noContent().build();
}
@PostMapping("/{userId}/reset-password")
public ResponseEntity<Void> resetPassword(@PathVariable String userId,
@RequestBody Map<String, String> body) {
vendorAdminService.resetAdminPassword(userId, body.get("password"));
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{userId}/mfa")
public ResponseEntity<Void> resetMfa(@PathVariable String userId) {
vendorAdminService.resetAdminMfa(userId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,146 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.notification.PasswordResetNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@Service
public class VendorAdminService {
private static final Logger log = LoggerFactory.getLogger(VendorAdminService.class);
private static final String VENDOR_ROLE_NAME = "saas-vendor";
private final LogtoManagementClient logtoClient;
private final AccountService accountService;
private final EmailConnectorService emailConnectorService;
private final PasswordResetNotificationService passwordNotificationService;
public VendorAdminService(LogtoManagementClient logtoClient,
AccountService accountService,
EmailConnectorService emailConnectorService,
PasswordResetNotificationService passwordNotificationService) {
this.logtoClient = logtoClient;
this.accountService = accountService;
this.emailConnectorService = emailConnectorService;
this.passwordNotificationService = passwordNotificationService;
}
// --- Records ---
public record VendorAdmin(String userId, String name, String email) {}
public record CreateAdminRequest(String email, String tempPassword) {}
public record CreateAdminResponse(boolean invited, String tempPassword) {}
// --- Methods ---
private String getVendorRoleId() {
var role = logtoClient.getRoleByName(VENDOR_ROLE_NAME);
if (role == null) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
"Vendor role '" + VENDOR_ROLE_NAME + "' not found in Logto");
}
return String.valueOf(role.get("id"));
}
public List<VendorAdmin> listAdmins() {
String roleId = getVendorRoleId();
var users = logtoClient.listRoleUsers(roleId);
return users.stream()
.map(u -> new VendorAdmin(
String.valueOf(u.get("id")),
u.get("name") != null ? String.valueOf(u.get("name")) : "",
u.get("primaryEmail") != null ? String.valueOf(u.get("primaryEmail")) : ""
))
.toList();
}
public CreateAdminResponse createAdmin(CreateAdminRequest request) {
if (request.email() == null || request.email().isBlank() || !request.email().contains("@")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Valid email address required");
}
String roleId = getVendorRoleId();
boolean emailConfigured = emailConnectorService.getEmailConnector() != null;
String userId;
boolean invited;
String tempPassword = null;
if (emailConfigured && (request.tempPassword() == null || request.tempPassword().isBlank())) {
// Invite via email — no org needed for vendor (global role only)
// Create user with primaryEmail only; no org assignment
userId = logtoClient.createAndInviteUser(request.email(), null, null);
invited = true;
log.info("Invited vendor admin: {}", request.email());
} else {
// Create with temporary password
tempPassword = request.tempPassword();
if (tempPassword == null || tempPassword.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Temporary password required when email connector is not configured");
}
accountService.validatePassword(tempPassword);
String username = request.email().substring(0, request.email().indexOf('@'));
userId = logtoClient.createUserWithPassword(username, tempPassword, null, null);
logtoClient.updateUserProfile(userId, Map.of("primaryEmail", request.email()));
invited = false;
log.info("Created vendor admin with credentials: {}", request.email());
}
logtoClient.assignGlobalRole(userId, roleId);
log.info("Assigned vendor role to user {}", userId);
return new CreateAdminResponse(invited, invited ? null : tempPassword);
}
public void removeAdmin(String userId, String requesterId) {
if (userId.equals(requesterId)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove yourself as administrator");
}
String roleId = getVendorRoleId();
logtoClient.revokeGlobalRole(userId, roleId);
log.info("Revoked vendor role from user {}", userId);
}
public void resetAdminPassword(String userId, String newPassword) {
verifyIsVendorAdmin(userId);
accountService.validatePassword(newPassword);
logtoClient.updateUserPassword(userId, newPassword);
log.info("Reset password for vendor admin {}", userId);
// Send notification email
try {
var user = logtoClient.getUser(userId);
if (user != null) {
String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
if (!email.isBlank()) {
passwordNotificationService.sendNotification(email);
}
}
} catch (Exception e) {
log.warn("Failed to send password reset notification: {}", e.getMessage());
}
}
public void resetAdminMfa(String userId) {
verifyIsVendorAdmin(userId);
logtoClient.deleteAllMfaVerifications(userId);
log.info("Reset MFA for vendor admin {}", userId);
}
private void verifyIsVendorAdmin(String userId) {
boolean isAdmin = listAdmins().stream()
.anyMatch(a -> userId.equals(a.userId()));
if (!isAdmin) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User is not a platform administrator");
}
}
}

View File

@@ -0,0 +1,59 @@
package net.siegeln.cameleer.saas.vendor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
@RestController
@RequestMapping("/api/vendor/auth-policy")
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public class VendorAuthPolicyController {
private static final Set<String> VALID_MFA_MODES = Set.of("off", "optional", "required");
private static final Set<String> VALID_PASSKEY_MODES = Set.of("optional", "preferred", "required");
private final VendorAuthPolicyRepository repository;
public VendorAuthPolicyController(VendorAuthPolicyRepository repository) {
this.repository = repository;
}
public record AuthPolicyResponse(String mfaMode, boolean passkeyEnabled, String passkeyMode) {
static AuthPolicyResponse from(VendorAuthPolicyEntity entity) {
return new AuthPolicyResponse(entity.getMfaMode(), entity.isPasskeyEnabled(), entity.getPasskeyMode());
}
}
public record AuthPolicyUpdateRequest(String mfaMode, Boolean passkeyEnabled, String passkeyMode) {}
@GetMapping
public ResponseEntity<AuthPolicyResponse> getPolicy() {
return ResponseEntity.ok(AuthPolicyResponse.from(repository.getPolicy()));
}
@PutMapping
public ResponseEntity<AuthPolicyResponse> updatePolicy(@RequestBody AuthPolicyUpdateRequest request) {
var policy = repository.getPolicy();
if (request.mfaMode() != null) {
if (!VALID_MFA_MODES.contains(request.mfaMode())) {
return ResponseEntity.badRequest().build();
}
policy.setMfaMode(request.mfaMode());
}
if (request.passkeyEnabled() != null) {
policy.setPasskeyEnabled(request.passkeyEnabled());
}
if (request.passkeyMode() != null) {
if (!VALID_PASSKEY_MODES.contains(request.passkeyMode())) {
return ResponseEntity.badRequest().build();
}
policy.setPasskeyMode(request.passkeyMode());
}
repository.save(policy);
return ResponseEntity.ok(AuthPolicyResponse.from(policy));
}
}

View File

@@ -0,0 +1,39 @@
package net.siegeln.cameleer.saas.vendor;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "vendor_auth_policy")
public class VendorAuthPolicyEntity {
@Id
@Column(name = "id")
private Integer id = 1;
@Column(name = "mfa_mode", nullable = false)
private String mfaMode = "off";
@Column(name = "passkey_enabled", nullable = false)
private boolean passkeyEnabled = false;
@Column(name = "passkey_mode", nullable = false)
private String passkeyMode = "optional";
@Column(name = "updated_at", nullable = false)
private Instant updatedAt = Instant.now();
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
public Integer getId() { return id; }
public String getMfaMode() { return mfaMode; }
public void setMfaMode(String mfaMode) { this.mfaMode = mfaMode; }
public boolean isPasskeyEnabled() { return passkeyEnabled; }
public void setPasskeyEnabled(boolean passkeyEnabled) { this.passkeyEnabled = passkeyEnabled; }
public String getPasskeyMode() { return passkeyMode; }
public void setPasskeyMode(String passkeyMode) { this.passkeyMode = passkeyMode; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.vendor;
import org.springframework.data.jpa.repository.JpaRepository;
public interface VendorAuthPolicyRepository extends JpaRepository<VendorAuthPolicyEntity, Integer> {
default VendorAuthPolicyEntity getPolicy() {
return findById(1).orElseGet(() -> {
var policy = new VendorAuthPolicyEntity();
return save(policy);
});
}
}

View File

@@ -0,0 +1,83 @@
package net.siegeln.cameleer.saas.vendor;
import com.cameleer.license.LicenseInfo;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseService;
import net.siegeln.cameleer.saas.license.dto.LicensePreset;
import net.siegeln.cameleer.saas.license.dto.VerifyLicenseRequest;
import net.siegeln.cameleer.saas.license.dto.VerifyLicenseResponse;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vendor")
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public class VendorLicenseController {
private final VendorTenantService vendorTenantService;
private final LicenseService licenseService;
public VendorLicenseController(VendorTenantService vendorTenantService,
LicenseService licenseService) {
this.vendorTenantService = vendorTenantService;
this.licenseService = licenseService;
}
@GetMapping("/license-presets")
public ResponseEntity<List<LicensePreset>> getPresets() {
List<LicensePreset> presets = Arrays.stream(Tier.values())
.map(t -> new LicensePreset(t.name(), LicenseDefaults.limitsForTier(t)))
.toList();
return ResponseEntity.ok(presets);
}
@PostMapping("/license/verify")
public ResponseEntity<VerifyLicenseResponse> verifyLicense(@RequestBody VerifyLicenseRequest request) {
if (request.token() == null || request.token().isBlank()) {
return ResponseEntity.badRequest().body(VerifyLicenseResponse.invalid("Token is required"));
}
var result = licenseService.verifyTokenSignature(request.token());
if (result.isEmpty()) {
return ResponseEntity.ok(VerifyLicenseResponse.invalid("Invalid signature or malformed token"));
}
LicenseInfo info = result.get();
String state = computeState(info);
return ResponseEntity.ok(new VerifyLicenseResponse(
true, state,
info.tenantId(), info.label(), info.limits(),
info.issuedAt(), info.expiresAt(), info.gracePeriodDays(),
null
));
}
@GetMapping("/signing-key/public")
public ResponseEntity<Map<String, String>> getPublicKey() {
return ResponseEntity.ok(Map.of("publicKey", vendorTenantService.getPublicKeyBase64()));
}
private String computeState(LicenseInfo info) {
Instant now = Instant.now();
if (now.isBefore(info.expiresAt()) || now.equals(info.expiresAt())) {
return "ACTIVE";
}
Instant graceEnd = info.expiresAt().plusSeconds((long) info.gracePeriodDays() * 86400);
if (now.isBefore(graceEnd) || now.equals(graceEnd)) {
return "GRACE";
}
return "EXPIRED";
}
}

View File

@@ -2,7 +2,9 @@ package net.siegeln.cameleer.saas.vendor;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
import net.siegeln.cameleer.saas.license.dto.LicenseBundleResponse;
import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
import net.siegeln.cameleer.saas.license.dto.MintLicenseRequest;
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@@ -55,7 +58,8 @@ public class VendorTenantController {
String serverState,
boolean serverHealthy,
String serverStatus,
LicenseResponse license
LicenseResponse license,
Map<String, Integer> usage
) {}
// --- Endpoints ---
@@ -82,8 +86,8 @@ public class VendorTenantController {
var license = vendorTenantService.getLicenseForTenant(tenant.getId());
if (license.isPresent() && license.get().getLimits() != null) {
var limits = license.get().getLimits();
if (limits.containsKey("agents")) {
agentLimit = ((Number) limits.get("agents")).intValue();
if (limits.containsKey("max_agents")) {
agentLimit = ((Number) limits.get("max_agents")).intValue();
}
}
return new VendorTenantSummary(
@@ -121,12 +125,23 @@ public class VendorTenantController {
.getLicenseForTenant(id)
.map(LicenseResponse::from)
.orElse(null);
Map<String, Integer> usage = new java.util.HashMap<>();
String endpoint = tenant.getServerEndpoint();
if (health.healthy() && endpoint != null && !endpoint.isBlank()) {
var serverApi = vendorTenantService.getServerApiClient();
usage.put("agents", serverApi.getAgentCount(endpoint));
usage.put("environments", serverApi.getEnvironmentCount(endpoint));
usage.put("apps", serverApi.getAppCount(endpoint));
}
return ResponseEntity.ok(new VendorTenantDetail(
TenantResponse.from(tenant),
serverStatus.state().name(),
health.healthy(),
health.status(),
license
license,
usage
));
})
.orElse(ResponseEntity.notFound().build());
@@ -189,12 +204,22 @@ public class VendorTenantController {
}
@PostMapping("/{id}/license")
public ResponseEntity<LicenseResponse> renewLicense(@PathVariable UUID id,
public ResponseEntity<LicenseBundleResponse> mintLicense(@PathVariable UUID id,
@RequestBody(required = false) MintLicenseRequest request,
@AuthenticationPrincipal Jwt jwt) {
UUID actorId = resolveActorId(jwt);
// Default to tier-preset, auto-push if no body provided
if (request == null) {
request = new MintLicenseRequest(null, null, null, null, null, true);
}
try {
var license = vendorTenantService.renewLicense(id, actorId);
return ResponseEntity.ok(LicenseResponse.from(license));
var tenant = vendorTenantService.getById(id)
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
var license = vendorTenantService.mintLicense(id, request, actorId);
String publicKey = vendorTenantService.getPublicKeyBase64();
boolean pushed = request.pushToServer() && tenant.getServerEndpoint() != null;
return ResponseEntity.ok(LicenseBundleResponse.from(
license, tenant.getSlug(), publicKey, pushed));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}

View File

@@ -9,8 +9,11 @@ import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import net.siegeln.cameleer.saas.provisioning.TenantDatabaseService;
import net.siegeln.cameleer.saas.provisioning.TenantDataCleanupService;
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseService;
import net.siegeln.cameleer.saas.license.SigningKeyService;
import net.siegeln.cameleer.saas.license.dto.MintLicenseRequest;
import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
import net.siegeln.cameleer.saas.provisioning.TenantProvisionRequest;
@@ -26,6 +29,8 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.util.List;
@@ -42,6 +47,7 @@ public class VendorTenantService {
private final TenantService tenantService;
private final TenantRepository tenantRepository;
private final LicenseService licenseService;
private final SigningKeyService signingKeyService;
private final TenantProvisioner tenantProvisioner;
private final ServerApiClient serverApiClient;
private final LogtoManagementClient logtoClient;
@@ -55,6 +61,7 @@ public class VendorTenantService {
public VendorTenantService(TenantService tenantService,
TenantRepository tenantRepository,
LicenseService licenseService,
SigningKeyService signingKeyService,
TenantProvisioner tenantProvisioner,
ServerApiClient serverApiClient,
LogtoManagementClient logtoClient,
@@ -67,6 +74,7 @@ public class VendorTenantService {
this.tenantService = tenantService;
this.tenantRepository = tenantRepository;
this.licenseService = licenseService;
this.signingKeyService = signingKeyService;
this.tenantProvisioner = tenantProvisioner;
this.serverApiClient = serverApiClient;
this.logtoClient = logtoClient;
@@ -116,9 +124,19 @@ public class VendorTenantService {
AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(),
null, null, "SUCCESS", null);
// 4. Provision server asynchronously (Docker containers, health check, config push)
// 4. Provision server asynchronously AFTER transaction commits
// (the async thread needs the tenant row to be visible)
if (tenantProvisioner.isAvailable()) {
self.provisionAsync(tenant.getId(), tenant.getSlug(), tenant.getTier().name(), license.getToken(), actorId);
UUID tenantId = tenant.getId();
String slug = tenant.getSlug();
String tierName = tenant.getTier().name();
String token = license.getToken();
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
self.provisionAsync(tenantId, slug, tierName, token, actorId);
}
});
}
return tenant;
@@ -352,27 +370,71 @@ public class VendorTenantService {
null, null, "SUCCESS", null);
}
/**
* Mint a license with configurable limits, expiry, grace period, and label.
* Optionally pushes to the tenant's server.
*/
@Transactional
public LicenseEntity renewLicense(UUID tenantId, UUID actorId) {
public LicenseEntity mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId) {
TenantEntity tenant = tenantService.getById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
// Revoke current license
licenseService.revokeLicense(tenantId, actorId);
// Generate new license
LicenseEntity newLicense = licenseService.generateLicense(tenant, DEFAULT_LICENSE_VALIDITY, actorId);
// Resolve limits: use provided limits, or fall back to tier preset
Map<String, Integer> limits;
if (request.limits() != null && !request.limits().isEmpty()) {
limits = request.limits();
} else if (request.tier() != null) {
limits = LicenseDefaults.limitsForTier(
net.siegeln.cameleer.saas.tenant.Tier.valueOf(request.tier()));
} else {
limits = LicenseDefaults.limitsForTier(tenant.getTier());
}
// Push to server
java.time.Instant expiresAt = request.expiresAt() != null
? request.expiresAt()
: java.time.Instant.now().plus(DEFAULT_LICENSE_VALIDITY);
int gracePeriodDays = request.gracePeriodDays() != null
? request.gracePeriodDays()
: LicenseDefaults.DEFAULT_GRACE_PERIOD_DAYS;
String label = request.label() != null
? request.label()
: tenant.getName() + " (" + tenant.getTier().name() + ")";
LicenseEntity newLicense = licenseService.generateLicense(
tenant, limits, expiresAt, gracePeriodDays, label, actorId);
// Push to server if requested and endpoint available
boolean pushed = false;
if (request.pushToServer()) {
String endpoint = tenant.getServerEndpoint();
if (endpoint != null && !endpoint.isBlank()) {
try {
serverApiClient.pushLicense(endpoint, newLicense.getToken());
pushed = true;
} catch (Exception e) {
log.warn("Failed to push renewed license to server for tenant {}: {}", tenant.getSlug(), e.getMessage());
log.warn("Failed to push license to server for tenant {}: {}", tenant.getSlug(), e.getMessage());
}
}
}
return newLicense;
}
/**
* Backward-compatible renewal using tier presets and auto-push.
*/
@Transactional
public LicenseEntity renewLicense(UUID tenantId, UUID actorId) {
var request = new MintLicenseRequest(null, null, null, null, null, true);
return mintLicense(tenantId, request, actorId);
}
public String getPublicKeyBase64() {
return signingKeyService.getPublicKeyBase64();
}
}

View File

@@ -20,7 +20,7 @@ spring:
oauth2:
resourceserver:
jwt:
issuer-uri: ${cameleer.saas.provisioning.publicprotocol:https}://${cameleer.saas.provisioning.publichost:localhost}/oidc
issuer-uri: ${cameleer.saas.provisioning.publicprotocol:https}://${cameleer.saas.identity.authhost:localhost}/oidc
jwk-set-uri: ${cameleer.saas.identity.logtoendpoint:http://cameleer-logto:3001}/oidc/jwks
management:
@@ -35,6 +35,7 @@ management:
cameleer:
saas:
identity:
authhost: ${CAMELEER_SAAS_IDENTITY_AUTHHOST:${cameleer.saas.provisioning.publichost:localhost}}
logtoendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT:}
logtopublicendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT:}
m2mclientid: ${CAMELEER_SAAS_IDENTITY_M2MCLIENTID:}
@@ -43,9 +44,9 @@ cameleer:
audience: ${CAMELEER_SAAS_IDENTITY_AUDIENCE:https://api.cameleer.local}
serverendpoint: ${CAMELEER_SAAS_IDENTITY_SERVERENDPOINT:http://cameleer-server:8081}
provisioning:
serverimage: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:gitea.siegeln.net/cameleer/cameleer-server:latest}
serveruiimage: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:gitea.siegeln.net/cameleer/cameleer-server-ui:latest}
runtimebaseimage: ${CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE:gitea.siegeln.net/cameleer/cameleer-runtime-base:latest}
serverimage: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:registry.cameleer.io/cameleer/cameleer-server:latest}
serveruiimage: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:registry.cameleer.io/cameleer/cameleer-server-ui:latest}
runtimebaseimage: ${CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE:registry.cameleer.io/cameleer/cameleer-runtime-base:latest}
networkname: ${CAMELEER_SAAS_PROVISIONING_NETWORKNAME:cameleer-saas_cameleer}
traefiknetwork: ${CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK:cameleer-traefik}
publichost: ${CAMELEER_SAAS_PROVISIONING_PUBLICHOST:localhost}
@@ -56,7 +57,7 @@ cameleer:
clickhouseurl: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEURL:jdbc:clickhouse://cameleer-clickhouse:8123/cameleer}
clickhouseuser: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEUSER:default}
clickhousepassword: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD:${CLICKHOUSE_PASSWORD:cameleer_ch}}
oidcissueruri: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.provisioning.publichost}/oidc
oidcissueruri: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.identity.authhost}/oidc
oidcjwkseturi: http://cameleer-logto:3001/oidc/jwks
corsorigins: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.provisioning.publichost}
certs:

View File

@@ -0,0 +1,34 @@
-- V002: License minter integration
-- Ed25519 signing keys for license minting
CREATE TABLE signing_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
public_key_b64 TEXT NOT NULL,
private_key_b64 TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Rename tiers: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
-- Single CASE pass to avoid double-rename
UPDATE tenants SET tier = CASE tier
WHEN 'LOW' THEN 'STARTER'
WHEN 'MID' THEN 'TEAM'
WHEN 'HIGH' THEN 'BUSINESS'
WHEN 'BUSINESS' THEN 'ENTERPRISE'
ELSE tier
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
UPDATE licenses SET tier = CASE tier
WHEN 'LOW' THEN 'STARTER'
WHEN 'MID' THEN 'TEAM'
WHEN 'HIGH' THEN 'BUSINESS'
WHEN 'BUSINESS' THEN 'ENTERPRISE'
ELSE tier
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
-- Add new license columns for Ed25519 model
ALTER TABLE licenses ADD COLUMN label VARCHAR(255);
ALTER TABLE licenses ADD COLUMN grace_period_days INTEGER NOT NULL DEFAULT 0;
-- Drop features column (server enforces caps, not feature flags)
ALTER TABLE licenses DROP COLUMN features;

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS vendor_auth_policy (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
mfa_mode VARCHAR(10) NOT NULL DEFAULT 'off',
passkey_enabled BOOLEAN NOT NULL DEFAULT false,
passkey_mode VARCHAR(10) NOT NULL DEFAULT 'optional',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO vendor_auth_policy (id) VALUES (1) ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,23 @@
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Lost in the dunes?</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">No worries — enter this code to reset your password and get back on the trail.</p>
<div style="text-align:center;margin:0 0 24px;">
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
</div>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email.</p>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This password reset was requested for your Cameleer account. Cameleer is an observability platform for Apache Camel integrations providing real-time route tracing, message inspection, and performance monitoring. For security, this code can only be used once. If you continue to have trouble accessing your account, please contact your administrator for assistance.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Quick checkpoint</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your verification code. Just making sure it's really you at the reins.</p>
<div style="text-align:center;margin:0 0 24px;">
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
</div>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">This code expires in 10 minutes.</p>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">You are receiving this email because a verification was requested on your Cameleer account. Cameleer is an observability platform for Apache Camel integrations providing real-time route tracing, message inspection, and performance monitoring. If you did not request this verification, please ignore this email or contact your administrator for assistance.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Your password was reset</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 16px;">Your Cameleer account password was successfully changed on {{timestamp}}.</p>
<div style="background:#FDF6EC;border:1px solid #e8e0d4;border-radius:6px;padding:12px 16px;margin:0 0 16px;">
<p style="color:#444;font-size:13px;line-height:1.5;margin:0;"><strong>Note:</strong> Multi-factor authentication (MFA) was not required for this password reset. We recommend enabling MFA to add an extra layer of security to your account.</p>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">If this wasn't you, contact your administrator immediately.</p>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This is an automated security notification from your Cameleer account. Cameleer is an observability platform for Apache Camel integrations providing real-time route tracing, message inspection, and performance monitoring. You are receiving this notification because a password change was completed. For your security, we recommend reviewing your account activity and ensuring your credentials are kept safe.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to the caravan!</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Enter this code to verify your email and claim your spot. The dunes wait for no one.</p>
<div style="text-align:center;margin:0 0 24px;">
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
</div>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed.</p>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">Cameleer is an observability platform for Apache Camel integrations. It provides real-time route tracing, message inspection, and performance monitoring to help your team debug and optimize integration flows. Your account gives you access to your dedicated Cameleer instance where you can connect your Camel applications and start monitoring immediately.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Back at the oasis already?</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your sign-in code. The caravan master is checking credentials.</p>
<div style="text-align:center;margin:0 0 24px;">
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
</div>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">This code expires in 10 minutes.</p>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">You are receiving this email because a sign-in attempt was made on your Cameleer account. Cameleer is an observability platform for Apache Camel integrations providing real-time route tracing, message inspection, and performance monitoring. If you did not initiate this sign-in, please ignore this email or contact your administrator.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -35,7 +35,7 @@ class LicenseControllerTest {
private String createTenantAndGetId() throws Exception {
String slug = "license-tenant-" + System.nanoTime();
var request = new CreateTenantRequest("License Test Org", slug, "MID", null, null);
var request = new CreateTenantRequest("License Test Org", slug, "TEAM", null, null);
var result = mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j
@@ -60,8 +60,7 @@ class LicenseControllerTest {
new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.token").isNotEmpty())
.andExpect(jsonPath("$.tier").value("MID"))
.andExpect(jsonPath("$.features.correlation").value(true));
.andExpect(jsonPath("$.tier").value("TEAM"));
}
@Test
@@ -78,7 +77,7 @@ class LicenseControllerTest {
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.tier").value("MID"));
.andExpect(jsonPath("$.tier").value("TEAM"));
}
@Test

View File

@@ -12,8 +12,10 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.time.Duration;
import java.util.Optional;
import java.util.Base64;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -30,11 +32,26 @@ class LicenseServiceTest {
@Mock
private AuditService auditService;
@Mock
private SigningKeyService signingKeyService;
private LicenseService licenseService;
private KeyPair testKeyPair;
@BeforeEach
void setUp() {
licenseService = new LicenseService(licenseRepository, auditService);
void setUp() throws Exception {
licenseService = new LicenseService(licenseRepository, auditService, signingKeyService);
testKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
}
private void stubPrivateKey() {
when(signingKeyService.getPrivateKey()).thenReturn(testKeyPair.getPrivate());
}
private void stubPublicKey() {
when(signingKeyService.getPublicKeyBase64()).thenReturn(
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()));
}
private TenantEntity createTenant(Tier tier) {
@@ -65,69 +82,71 @@ class LicenseServiceTest {
}
@Test
void generateLicense_producesUuidToken() {
var tenant = createTenant(Tier.MID);
void generateLicense_producesSignedToken() {
var tenant = createTenant(Tier.TEAM);
stubPrivateKey();
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
assertThat(license.getToken()).isNotBlank();
// Token must be a valid UUID string
assertThat(UUID.fromString(license.getToken())).isNotNull();
assertThat(license.getTier()).isEqualTo("MID");
}
@Test
void generateLicense_setsCorrectFeaturesForTier() {
var tenant = createTenant(Tier.HIGH);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
assertThat(license.getFeatures()).containsEntry("debugger", true);
assertThat(license.getFeatures()).containsEntry("replay", true);
// Ed25519 signed token contains a '.' separator between payload and signature
assertThat(license.getToken()).contains(".");
assertThat(license.getTier()).isEqualTo("TEAM");
}
@Test
void generateLicense_setsCorrectLimitsForTier() {
var tenant = createTenant(Tier.LOW);
var tenant = createTenant(Tier.STARTER);
stubPrivateKey();
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
assertThat(license.getLimits()).containsEntry("max_agents", 3);
assertThat(license.getLimits()).containsEntry("retention_days", 7);
assertThat(license.getLimits()).containsEntry("max_agents", 20);
assertThat(license.getLimits()).containsEntry("max_environments", 2);
assertThat(license.getLimits()).containsEntry("max_execution_retention_days", 7);
}
@Test
void verifyLicenseToken_validTokenReturnsPayload() {
var tenant = createTenant(Tier.MID);
void generateLicense_setsGracePeriodAndLabel() {
var tenant = createTenant(Tier.BUSINESS);
stubPrivateKey();
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
assertThat(license.getGracePeriodDays()).isEqualTo(LicenseDefaults.DEFAULT_GRACE_PERIOD_DAYS);
assertThat(license.getLabel()).contains("Test Tenant");
}
@Test
void verifyToken_validTokenReturnsLicenseInfo() {
var tenant = createTenant(Tier.TEAM);
stubPrivateKey();
stubPublicKey();
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
when(licenseRepository.findByToken(license.getToken())).thenReturn(Optional.of(license));
var result = licenseService.verifyToken(license.getToken(), "test");
var payload = licenseService.verifyLicenseToken(license.getToken());
assertThat(payload).isPresent();
assertThat(payload.get().get("tier")).isEqualTo("MID");
assertThat(payload.get().get("tenant_id")).isEqualTo(tenant.getId().toString());
assertThat(result).isPresent();
assertThat(result.get().tenantId()).isEqualTo("test");
}
@Test
void verifyLicenseToken_unknownTokenReturnsEmpty() {
when(licenseRepository.findByToken(any())).thenReturn(Optional.empty());
var payload = licenseService.verifyLicenseToken("unknown-token");
assertThat(payload).isEmpty();
void verifyToken_invalidTokenReturnsEmpty() {
stubPublicKey();
var result = licenseService.verifyToken("invalid-token", "test");
assertThat(result).isEmpty();
}
@Test
void generateLicense_logsAuditEvent() {
var tenant = createTenant(Tier.LOW);
var tenant = createTenant(Tier.STARTER);
var actorId = UUID.randomUUID();
stubPrivateKey();
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
licenseService.generateLicense(tenant, Duration.ofDays(30), actorId);

View File

@@ -52,7 +52,7 @@ class TenantPortalControllerTest {
var tenant = new TenantEntity();
tenant.setName(name);
tenant.setSlug(slug);
tenant.setTier(Tier.LOW);
tenant.setTier(Tier.STARTER);
tenant.setStatus(TenantStatus.ACTIVE);
tenant.setLogtoOrgId(orgId);
return tenantRepository.save(tenant);

View File

@@ -1,5 +1,6 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.config.TenantContext;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient;
@@ -46,6 +47,9 @@ class TenantPortalServiceTest {
@Mock
private TenantProvisioner tenantProvisioner;
@Mock
private AccountService accountService;
private final ProvisioningProperties provisioningProps = new ProvisioningProperties(
null, null, null, null, null, "test.example.com", "https", null, null, null, null, null, null, null, null, null);
@@ -56,7 +60,7 @@ class TenantPortalServiceTest {
@BeforeEach
void setUp() {
TenantContext.setTenantId(tenantId);
tenantPortalService = new TenantPortalService(tenantService, licenseService, serverApiClient, logtoClient, tenantProvisioner, provisioningProps, null);
tenantPortalService = new TenantPortalService(tenantService, licenseService, serverApiClient, logtoClient, tenantProvisioner, provisioningProps, null, accountService);
}
@AfterEach
@@ -85,8 +89,8 @@ class TenantPortalServiceTest {
license.setToken("test-token-" + UUID.randomUUID());
license.setIssuedAt(Instant.now());
license.setExpiresAt(expiresAt);
license.setFeatures(Map.of("feature1", true));
license.setLimits(Map.of("maxApps", 10));
license.setLimits(Map.of("max_apps", 10));
license.setGracePeriodDays(14);
var f = LicenseEntity.class.getDeclaredField("id");
f.setAccessible(true);
f.set(license, UUID.randomUUID());
@@ -97,10 +101,10 @@ class TenantPortalServiceTest {
@Test
void getDashboard_returnsDashboardData() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.ACTIVE);
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER, TenantStatus.ACTIVE);
tenant.setServerEndpoint("http://server:8080");
var expiresAt = Instant.now().plus(Duration.ofDays(30));
var license = licenseWithId(tenantId, "LOW", expiresAt);
var license = licenseWithId(tenantId, "STARTER", expiresAt);
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(serverApiClient.getHealth("http://server:8080")).thenReturn(new ServerHealthResponse(true, "UP"));
@@ -112,22 +116,21 @@ class TenantPortalServiceTest {
assertThat(result.name()).isEqualTo("Acme Corp");
assertThat(result.slug()).isEqualTo("acme-corp");
assertThat(result.tier()).isEqualTo("LOW");
assertThat(result.tier()).isEqualTo("STARTER");
assertThat(result.status()).isEqualTo("ACTIVE");
assertThat(result.serverHealthy()).isTrue();
assertThat(result.serverStatus()).isEqualTo("UP");
assertThat(result.serverEndpoint()).isEqualTo("http://server:8080");
assertThat(result.licenseTier()).isEqualTo("LOW");
assertThat(result.licenseTier()).isEqualTo("STARTER");
assertThat(result.licenseDaysRemaining()).isGreaterThanOrEqualTo(29);
assertThat(result.limits()).isNotEmpty();
assertThat(result.features()).isNotEmpty();
assertThat(result.agentCount()).isEqualTo(3);
assertThat(result.environmentCount()).isEqualTo(1);
}
@Test
void getDashboard_handlesNoServer() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.PROVISIONING);
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER, TenantStatus.PROVISIONING);
// serverEndpoint is null by default
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
@@ -148,9 +151,9 @@ class TenantPortalServiceTest {
@Test
void getLicense_returnsLicenseData() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.ACTIVE);
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER, TenantStatus.ACTIVE);
var expiresAt = Instant.now().plus(Duration.ofDays(60));
var license = licenseWithId(tenantId, "LOW", expiresAt);
var license = licenseWithId(tenantId, "STARTER", expiresAt);
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.of(license));
@@ -159,18 +162,18 @@ class TenantPortalServiceTest {
assertThat(result).isNotNull();
assertThat(result.id()).isEqualTo(license.getId());
assertThat(result.tier()).isEqualTo("LOW");
assertThat(result.tier()).isEqualTo("STARTER");
assertThat(result.token()).isEqualTo(license.getToken());
assertThat(result.issuedAt()).isEqualTo(license.getIssuedAt());
assertThat(result.expiresAt()).isEqualTo(expiresAt);
assertThat(result.daysRemaining()).isGreaterThanOrEqualTo(59);
assertThat(result.features()).isEqualTo(Map.of("feature1", true));
assertThat(result.limits()).isEqualTo(Map.of("maxApps", 10));
assertThat(result.limits()).isEqualTo(Map.of("max_apps", 10));
assertThat(result.gracePeriodDays()).isEqualTo(14);
}
@Test
void getLicense_returnsNullWhenNoLicense() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.ACTIVE);
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER, TenantStatus.ACTIVE);
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.empty());
@@ -184,7 +187,7 @@ class TenantPortalServiceTest {
@Test
void getSettings_returnsSettingsData() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.MID, TenantStatus.ACTIVE);
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.TEAM, TenantStatus.ACTIVE);
tenant.setServerEndpoint("http://server:8080");
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
@@ -193,7 +196,7 @@ class TenantPortalServiceTest {
assertThat(result.name()).isEqualTo("Acme Corp");
assertThat(result.slug()).isEqualTo("acme-corp");
assertThat(result.tier()).isEqualTo("MID");
assertThat(result.tier()).isEqualTo("TEAM");
assertThat(result.status()).isEqualTo("ACTIVE");
assertThat(result.serverEndpoint()).isEqualTo("https://test.example.com/t/acme-corp/");
assertThat(result.createdAt()).isNull(); // no @PrePersist called in test, createdAt is null

View File

@@ -35,7 +35,7 @@ class TenantControllerTest {
@Test
void createTenant_returns201() throws Exception {
var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "LOW", null, null);
var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "STARTER", null, null);
mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j
@@ -47,7 +47,7 @@ class TenantControllerTest {
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Test Org"))
.andExpect(jsonPath("$.tier").value("LOW"))
.andExpect(jsonPath("$.tier").value("STARTER"))
.andExpect(jsonPath("$.status").value("PROVISIONING"));
}

View File

@@ -41,7 +41,7 @@ class TenantServiceTest {
@Test
void create_savesNewTenantWithCorrectFields() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "MID", null, null);
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "TEAM", null, null);
var actorId = UUID.randomUUID();
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
@@ -51,7 +51,7 @@ class TenantServiceTest {
assertThat(result.getName()).isEqualTo("Acme Corp");
assertThat(result.getSlug()).isEqualTo("acme-corp");
assertThat(result.getTier()).isEqualTo(Tier.MID);
assertThat(result.getTier()).isEqualTo(Tier.TEAM);
assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
}
@@ -82,7 +82,7 @@ class TenantServiceTest {
}
@Test
void create_defaultsToLowTier() {
void create_defaultsToStarterTier() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null);
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
@@ -90,7 +90,7 @@ class TenantServiceTest {
var result = tenantService.create(request, UUID.randomUUID());
assertThat(result.getTier()).isEqualTo(Tier.LOW);
assertThat(result.getTier()).isEqualTo(Tier.STARTER);
}
@Test

View File

@@ -0,0 +1,68 @@
package net.siegeln.cameleer.saas.vendor;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class EmailTemplateLoadingTest {
private static final String[] TEMPLATE_FILES = {
"email-templates/register.html",
"email-templates/sign-in.html",
"email-templates/forgot-password.html",
"email-templates/generic.html"
};
@Test
void allTemplateFilesExistOnClasspath() {
for (String path : TEMPLATE_FILES) {
var resource = new ClassPathResource(path);
assertTrue(resource.exists(), "Template file missing: " + path);
}
}
@Test
void templatesContainCodePlaceholder() throws IOException {
for (String path : TEMPLATE_FILES) {
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
assertTrue(content.contains("{{code}}"),
path + " must contain {{code}} placeholder");
}
}
@Test
void templatesContainWatermarkPlaceholder() throws IOException {
for (String path : TEMPLATE_FILES) {
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
assertTrue(content.contains("{{watermarkUrl}}"),
path + " must contain {{watermarkUrl}} placeholder");
}
}
@Test
void watermarkPlaceholderIsReplaced() throws IOException {
String content = new ClassPathResource("email-templates/register.html")
.getContentAsString(StandardCharsets.UTF_8);
String resolved = content.replace("{{watermarkUrl}}",
"https://example.com/platform/assets/email-watermark.png");
assertFalse(resolved.contains("{{watermarkUrl}}"));
assertTrue(resolved.contains("https://example.com/platform/assets/email-watermark.png"));
}
@Test
void templatesContainBrandElements() throws IOException {
for (String path : TEMPLATE_FILES) {
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
assertTrue(content.contains("Cameleer.io"),
path + " must contain Cameleer.io header");
assertTrue(content.contains("Apache Camel observability"),
path + " must contain tagline");
assertTrue(content.contains("#C6820E"),
path + " must use brand color");
}
}
}

View File

@@ -51,7 +51,7 @@ class VendorTenantControllerTest {
@Test
void listTenants_returnsAllTenants() throws Exception {
String slug = "list-test-" + System.nanoTime();
createTenant("List Test Org", slug, "LOW");
createTenant("List Test Org", slug, "STARTER");
mockMvc.perform(get("/api/vendor/tenants")
.with(jwt().jwt(j -> j
@@ -65,7 +65,7 @@ class VendorTenantControllerTest {
@Test
void createTenant_returns201() throws Exception {
String slug = "create-test-" + System.nanoTime();
var request = new CreateTenantRequest("Create Test Org", slug, "MID", null, null);
var request = new CreateTenantRequest("Create Test Org", slug, "TEAM", null, null);
mockMvc.perform(post("/api/vendor/tenants")
.with(jwt().jwt(j -> j
@@ -78,16 +78,16 @@ class VendorTenantControllerTest {
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Create Test Org"))
.andExpect(jsonPath("$.slug").value(slug))
.andExpect(jsonPath("$.tier").value("MID"))
.andExpect(jsonPath("$.tier").value("TEAM"))
.andExpect(jsonPath("$.id").isNotEmpty());
}
@Test
void createTenant_returns409ForDuplicateSlug() throws Exception {
String slug = "duplicate-vendor-" + System.nanoTime();
createTenant("First Org", slug, "LOW");
createTenant("First Org", slug, "STARTER");
var request = new CreateTenantRequest("Second Org", slug, "LOW", null, null);
var request = new CreateTenantRequest("Second Org", slug, "STARTER", null, null);
mockMvc.perform(post("/api/vendor/tenants")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
@@ -102,7 +102,7 @@ class VendorTenantControllerTest {
@Test
void getTenantDetail_returnsDetailWithServerStatus() throws Exception {
String slug = "detail-test-" + System.nanoTime();
String id = createTenant("Detail Test Org", slug, "LOW");
String id = createTenant("Detail Test Org", slug, "STARTER");
mockMvc.perform(get("/api/vendor/tenants/" + id)
.with(jwt().jwt(j -> j
@@ -118,7 +118,7 @@ class VendorTenantControllerTest {
@Test
void suspendTenant_returnsUpdatedStatus() throws Exception {
String slug = "suspend-test-" + System.nanoTime();
String id = createTenant("Suspend Test Org", slug, "LOW");
String id = createTenant("Suspend Test Org", slug, "STARTER");
mockMvc.perform(post("/api/vendor/tenants/" + id + "/suspend")
.with(jwt().jwt(j -> j
@@ -132,7 +132,7 @@ class VendorTenantControllerTest {
@Test
void deleteTenant_returns204() throws Exception {
String slug = "delete-test-" + System.nanoTime();
String id = createTenant("Delete Test Org", slug, "LOW");
String id = createTenant("Delete Test Org", slug, "STARTER");
mockMvc.perform(delete("/api/vendor/tenants/" + id)
.with(jwt().jwt(j -> j
@@ -144,7 +144,7 @@ class VendorTenantControllerTest {
@Test
void createTenant_returns401WithoutAuth() throws Exception {
var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), "LOW", null, null);
var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), "STARTER", null, null);
mockMvc.perform(post("/api/vendor/tenants")
.contentType(MediaType.APPLICATION_JSON)

Some files were not shown because too many files have changed in this diff Show More