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>
15 KiB
Vendor Admin Management & Account Settings
Date: 2026-04-27 Status: Approved
Problem
- The vendor console supports only a single platform admin (created during bootstrap via
SAAS_ADMIN_USER). There is no way to add additional administrators. - 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/accountroute 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 fromMfaEnforcementFilter(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
- Client sends
POST /api/account/passwordwith{ currentPassword, newPassword } AccountService.changePassword(): a.validatePassword(newPassword)— min 8 chars b. Fetch user email vialogtoClient.getUser(userId)c. Attempt ROPC token exchange:POST /oidc/tokenwithgrant_type=password, user's email +currentPasswordagainst the SaaS OIDC app d. If token exchange fails → 400 "Current password is incorrect" e.logtoClient.updateUserPassword(userId, newPassword)f. FirepasswordResetNotificationService.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)+ assignsaas-vendorrole → returns{ invited: true } - If not configured or temp password provided:
logtoClient.createUserWithPassword(email, tempPassword)+ assignsaas-vendorrole → 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:
- User enters email
- Logto Experience API sends reset code
- User enters code + new password
- 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 |