# 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 |