Files
cameleer-saas/docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md
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

15 KiB

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

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