diff --git a/docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md b/docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md new file mode 100644 index 0000000..9cae8d6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md @@ -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 |