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>
This commit is contained in:
hsiegeln
2026-04-27 14:20:49 +02:00
parent 292adeea4c
commit 86d9ba4985

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 |