Files
cameleer-saas/docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md

2369 lines
83 KiB
Markdown
Raw Normal View History

# Vendor Admin Management & Account Settings — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add multi-vendor admin management and a shared account settings page (profile, password with current-password verification, MFA self-service) accessible to any authenticated user.
**Architecture:** New `account/` package extracts user-level identity operations from `TenantPortalService` into `AccountService`. Vendor admin CRUD uses a new `VendorAdminService` in the existing `vendor/` package. Frontend extracts MFA/passkey/password components from `SettingsPage.tsx` into shared components, composes them in a new `AccountSettingsPage` at `/settings/account`. TopBar's existing `userMenuItems` prop wires up the user dropdown.
**Tech Stack:** Java 21 / Spring Boot 3, React 19, @tanstack/react-query, @cameleer/design-system, Logto Management API
**Spec:** `docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md`
---
## File Structure
### New Files
| File | Responsibility |
|------|---------------|
| `src/.../account/AccountService.java` | User-level identity operations: profile, password (with verification), MFA, passkeys |
| `src/.../account/AccountController.java` | `/api/account/*` REST endpoints — any authenticated user |
| `src/.../vendor/VendorAdminService.java` | Vendor admin CRUD: list, create/invite, remove, reset password/MFA |
| `src/.../vendor/VendorAdminController.java` | `/api/vendor/admins/*` REST endpoints — platform:admin only |
| `ui/src/api/account-hooks.ts` | React Query hooks for `/api/account/*` |
| `ui/src/api/vendor-admin-hooks.ts` | React Query hooks for `/api/vendor/admins/*` |
| `ui/src/components/account/ProfileSection.tsx` | Display name + email form |
| `ui/src/components/account/PasswordChangeSection.tsx` | Current + new password form |
| `ui/src/components/account/MfaSection.tsx` | TOTP setup/remove/backup codes |
| `ui/src/components/account/PasskeySection.tsx` | Passkey list/rename/delete + nudge banner |
| `ui/src/pages/AccountSettingsPage.tsx` | Shared account settings — composes all four sections |
| `ui/src/pages/vendor/VendorAdminsPage.tsx` | Vendor admin list + add/remove/reset actions |
### Modified Files
| File | Change |
|------|--------|
| `src/.../identity/LogtoManagementClient.java` | Add `verifyUserPassword`, `listRoleUsers`, `assignGlobalRole`, `revokeGlobalRole`, `getRoleByName` |
| `src/.../config/SecurityConfig.java` | Add `/api/account/**` as `authenticated()` |
| `src/.../config/MfaEnforcementFilter.java` | Add `/api/account/mfa/` to exempt prefixes |
| `src/.../portal/TenantPortalService.java` | Delegate MFA/password/passkey methods to `AccountService` |
| `src/.../onboarding/OnboardingService.java` | Use `AccountService.updateDisplayName()` |
| `ui/src/types/api.ts` | Add `AccountProfile`, `VendorAdmin`, `CreateAdminRequest`, `CreateAdminResponse` types |
| `ui/src/api/tenant-hooks.ts` | Replace MFA/password hook implementations with re-exports from `account-hooks.ts` |
| `ui/src/pages/tenant/SettingsPage.tsx` | Import shared components from `components/account/` |
| `ui/src/components/Layout.tsx` | Add `userMenuItems` prop to TopBar with "Account Settings" item |
| `ui/src/router.tsx` | Add `/settings/account` and `/vendor/admins` routes |
All Java paths below are relative to `src/main/java/net/siegeln/cameleer/saas/`.
---
## Task 1: LogtoManagementClient — New Methods
**Files:**
- Modify: `identity/LogtoManagementClient.java`
These five new methods follow the exact same pattern as existing methods in this file: get an access token, make an HTTP call with `RestClient`, parse the JSON response.
- [ ] **Step 1: Add `verifyUserPassword` method**
Add after the existing `updateUserPassword` method (after line ~527):
```java
/**
* Verify a user's current password via Management API.
* Returns true if password is correct, false otherwise.
*/
public boolean verifyUserPassword(String userId, String password) {
try {
var token = getAccessToken();
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/password/verify")
.header("Authorization", "Bearer " + token)
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.body(Map.of("password", password))
.retrieve()
.toBodilessEntity();
return true;
} catch (org.springframework.web.client.HttpClientErrorException e) {
if (e.getStatusCode().value() == 422 || e.getStatusCode().value() == 400) {
return false;
}
throw e;
}
}
```
- [ ] **Step 2: Add role management methods**
Add after the existing `getUser` method (after line ~674):
```java
/**
* List all users assigned to a global role.
*/
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listRoleUsers(String roleId) {
var token = getAccessToken();
var response = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users?page=1&page_size=200")
.header("Authorization", "Bearer " + token)
.retrieve()
.body(List.class);
return response != null ? response : List.of();
}
/**
* Find a global role by its exact name. Returns the role map or null.
*/
@SuppressWarnings("unchecked")
public Map<String, Object> getRoleByName(String roleName) {
var token = getAccessToken();
var response = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/roles?search=" +
java.net.URLEncoder.encode(roleName, java.nio.charset.StandardCharsets.UTF_8) +
"&page=1&page_size=20")
.header("Authorization", "Bearer " + token)
.retrieve()
.body(List.class);
if (response == null) return null;
return ((List<Map<String, Object>>) response).stream()
.filter(r -> roleName.equals(r.get("name")))
.findFirst()
.orElse(null);
}
/**
* Assign a global role to a user.
*/
public void assignGlobalRole(String userId, String roleId) {
var token = getAccessToken();
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users")
.header("Authorization", "Bearer " + token)
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.body(Map.of("userIds", List.of(userId)))
.retrieve()
.toBodilessEntity();
}
/**
* Revoke a global role from a user.
*/
public void revokeGlobalRole(String userId, String roleId) {
var token = getAccessToken();
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users/" + userId)
.header("Authorization", "Bearer " + token)
.retrieve()
.toBodilessEntity();
}
```
- [ ] **Step 3: Verify compilation**
Run: `cd src && ../mvnw compile -pl .. -q 2>&1 | tail -5` (or full Maven compile)
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
git commit -m "feat: add password verify and role management methods to LogtoManagementClient"
```
---
## Task 2: AccountService — Extract from TenantPortalService
**Files:**
- Create: `account/AccountService.java`
This service extracts ALL user-level identity operations from `TenantPortalService`. The TOTP helper methods (`computeTotp`, `base32Encode`, `base32Decode`) move here since they're only used by MFA operations.
- [ ] **Step 1: Create AccountService**
Create `src/main/java/net/siegeln/cameleer/saas/account/AccountService.java`:
```java
package net.siegeln.cameleer.saas.account;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.notification.PasswordResetNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Service
public class AccountService {
private static final Logger log = LoggerFactory.getLogger(AccountService.class);
private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private final LogtoManagementClient logtoClient;
private final PasswordResetNotificationService passwordNotificationService;
public AccountService(LogtoManagementClient logtoClient,
PasswordResetNotificationService passwordNotificationService) {
this.logtoClient = logtoClient;
this.passwordNotificationService = passwordNotificationService;
}
// --- Records ---
public record ProfileData(String userId, String name, String email) {}
public record MfaStatusData(boolean enrolled, boolean hasBackupCodes, boolean passkeyEnrolled, int passkeyCount) {}
public record MfaSetupData(String secret, String secretQrCode) {}
public record BackupCodesData(List<String> codes) {}
public record PasskeyCredential(String id, String name, String agent, String createdAt) {}
// --- Profile ---
public ProfileData getProfile(String userId) {
var user = logtoClient.getUser(userId);
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
}
return new ProfileData(
userId,
String.valueOf(user.getOrDefault("name", "")),
String.valueOf(user.getOrDefault("primaryEmail", ""))
);
}
public void updateDisplayName(String userId, String name) {
if (name == null || name.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Display name must not be blank");
}
logtoClient.updateUserProfile(userId, Map.of("name", name.trim()));
}
// --- Password ---
public void validatePassword(String password) {
if (password == null || password.length() < 8) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password must be at least 8 characters");
}
}
public void changePassword(String userId, String currentPassword, String newPassword) {
validatePassword(newPassword);
if (!logtoClient.verifyUserPassword(userId, currentPassword)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Current password is incorrect");
}
logtoClient.updateUserPassword(userId, newPassword);
// Send confirmation email asynchronously
try {
var user = logtoClient.getUser(userId);
if (user != null) {
String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
if (!email.isBlank()) {
passwordNotificationService.sendNotification(email);
}
}
} catch (Exception e) {
log.warn("Failed to send password change notification for user {}: {}", userId, e.getMessage());
}
}
// --- MFA ---
public MfaStatusData getMfaStatus(String userId) {
var verifications = logtoClient.getUserMfaVerifications(userId);
boolean enrolled = verifications.stream()
.anyMatch(v -> "Totp".equals(String.valueOf(v.get("type"))));
boolean hasBackupCodes = verifications.stream()
.anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type"))));
long passkeyCount = verifications.stream()
.filter(v -> "WebAuthn".equals(String.valueOf(v.get("type"))))
.count();
return new MfaStatusData(enrolled, hasBackupCodes, passkeyCount > 0, (int) passkeyCount);
}
public MfaSetupData setupTotp(String userId) {
byte[] secretBytes = new byte[20];
new SecureRandom().nextBytes(secretBytes);
String secret = base32Encode(secretBytes);
var result = logtoClient.createTotpVerification(userId, secret);
String qrCode = result.containsKey("secretQrCode")
? String.valueOf(result.get("secretQrCode"))
: String.valueOf(result.getOrDefault("qrCode", ""));
return new MfaSetupData(secret, qrCode);
}
public boolean verifyTotpCode(String secret, String code) {
if (code == null || code.length() != 6) return false;
long currentStep = Instant.now().getEpochSecond() / 30;
for (int drift = -1; drift <= 1; drift++) {
String computed = computeTotp(secret, currentStep + drift);
if (code.equals(computed)) return true;
}
return false;
}
public BackupCodesData generateBackupCodes(String userId) {
var result = logtoClient.createBackupCodes(userId);
@SuppressWarnings("unchecked")
List<String> codes = (List<String>) result.get("codes");
return new BackupCodesData(codes != null ? codes : List.of());
}
public void removeMfa(String userId) {
var verifications = logtoClient.getUserMfaVerifications(userId);
for (var v : verifications) {
logtoClient.deleteMfaVerification(userId, String.valueOf(v.get("id")));
}
}
// --- Passkeys ---
public List<PasskeyCredential> listPasskeys(String userId) {
var credentials = logtoClient.getWebAuthnCredentials(userId);
return credentials.stream()
.map(c -> new PasskeyCredential(
String.valueOf(c.get("id")),
c.get("name") != null ? String.valueOf(c.get("name")) : null,
c.get("agent") != null ? String.valueOf(c.get("agent")) : null,
c.get("createdAt") != null ? String.valueOf(c.get("createdAt")) : null
))
.toList();
}
public void renamePasskey(String userId, String credentialId, String name) {
// Verify credential belongs to user
var credentials = logtoClient.getWebAuthnCredentials(userId);
boolean owns = credentials.stream()
.anyMatch(c -> credentialId.equals(String.valueOf(c.get("id"))));
if (!owns) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
}
logtoClient.renameMfaVerification(userId, credentialId, name);
}
public void deletePasskey(String userId, String credentialId) {
var credentials = logtoClient.getWebAuthnCredentials(userId);
boolean owns = credentials.stream()
.anyMatch(c -> credentialId.equals(String.valueOf(c.get("id"))));
if (!owns) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
}
logtoClient.deleteMfaVerification(userId, credentialId);
}
// --- MFA Preference ---
public void setMfaMethodPreference(String userId, String preference) {
if (!"totp".equals(preference) && !"webauthn".equals(preference)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid MFA preference: must be 'totp' or 'webauthn'");
}
logtoClient.updateUserCustomData(userId, Map.of("mfa_method_preference", preference));
}
// --- TOTP helpers (moved from TenantPortalService) ---
private String computeTotp(String base32Secret, long timeStep) {
try {
byte[] key = base32Decode(base32Secret);
byte[] data = ByteBuffer.allocate(8).putLong(timeStep).array();
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
byte[] hash = mac.doFinal(data);
int offset = hash[hash.length - 1] & 0x0F;
int code = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
return String.format("%06d", code % 1_000_000);
} catch (Exception e) {
log.error("TOTP computation failed", e);
return "";
}
}
String base32Encode(byte[] data) {
StringBuilder sb = new StringBuilder();
int buffer = 0, bitsLeft = 0;
for (byte b : data) {
buffer = (buffer << 8) | (b & 0xFF);
bitsLeft += 8;
while (bitsLeft >= 5) {
sb.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
bitsLeft -= 5;
}
}
if (bitsLeft > 0) {
sb.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F));
}
return sb.toString();
}
byte[] base32Decode(String encoded) {
String clean = encoded.replaceAll("[=\\s]", "").toUpperCase();
int byteCount = clean.length() * 5 / 8;
byte[] result = new byte[byteCount];
int buffer = 0, bitsLeft = 0, index = 0;
for (char c : clean.toCharArray()) {
int val = BASE32_ALPHABET.indexOf(c);
if (val < 0) continue;
buffer = (buffer << 5) | val;
bitsLeft += 5;
if (bitsLeft >= 8) {
result[index++] = (byte) (buffer >> (bitsLeft - 8));
bitsLeft -= 8;
}
}
return result;
}
}
```
- [ ] **Step 2: Verify compilation**
Run: `mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 3: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/account/AccountService.java
git commit -m "feat: add AccountService extracting user identity operations from TenantPortalService"
```
---
## Task 3: AccountController — REST Endpoints
**Files:**
- Create: `account/AccountController.java`
- [ ] **Step 1: Create AccountController**
Create `src/main/java/net/siegeln/cameleer/saas/account/AccountController.java`:
```java
package net.siegeln.cameleer.saas.account;
import net.siegeln.cameleer.saas.account.AccountService.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/account")
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
// --- Profile ---
@GetMapping("/profile")
public ProfileData getProfile(@AuthenticationPrincipal Jwt jwt) {
return accountService.getProfile(jwt.getSubject());
}
@PatchMapping("/profile")
public ResponseEntity<Void> updateProfile(@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, String> body) {
String name = body.get("name");
accountService.updateDisplayName(jwt.getSubject(), name);
return ResponseEntity.noContent().build();
}
// --- Password ---
record PasswordChangeRequest(String currentPassword, String newPassword) {}
@PostMapping("/password")
public ResponseEntity<Void> changePassword(@AuthenticationPrincipal Jwt jwt,
@RequestBody PasswordChangeRequest request) {
accountService.changePassword(jwt.getSubject(), request.currentPassword(), request.newPassword());
return ResponseEntity.noContent().build();
}
// --- MFA ---
@GetMapping("/mfa/status")
public MfaStatusData getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
return accountService.getMfaStatus(jwt.getSubject());
}
@PostMapping("/mfa/totp/setup")
public MfaSetupData setupTotp(@AuthenticationPrincipal Jwt jwt) {
return accountService.setupTotp(jwt.getSubject());
}
record TotpVerifyRequest(String secret, String code) {}
@PostMapping("/mfa/totp/verify")
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
@RequestBody TotpVerifyRequest request) {
boolean ok = accountService.verifyTotpCode(request.secret(), request.code());
if (!ok) {
return Map.of("verified", false);
}
return Map.of("verified", true);
}
@PostMapping("/mfa/backup-codes")
public BackupCodesData generateBackupCodes(@AuthenticationPrincipal Jwt jwt) {
return accountService.generateBackupCodes(jwt.getSubject());
}
@DeleteMapping("/mfa/totp")
public ResponseEntity<Void> removeTotp(@AuthenticationPrincipal Jwt jwt) {
accountService.removeMfa(jwt.getSubject());
return ResponseEntity.noContent().build();
}
// --- Passkeys ---
@GetMapping("/mfa/webauthn")
public List<PasskeyCredential> listPasskeys(@AuthenticationPrincipal Jwt jwt) {
return accountService.listPasskeys(jwt.getSubject());
}
@PatchMapping("/mfa/webauthn/{id}/name")
public ResponseEntity<Void> renamePasskey(@AuthenticationPrincipal Jwt jwt,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String name = body.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().build();
}
accountService.renamePasskey(jwt.getSubject(), id, name.trim());
return ResponseEntity.noContent().build();
}
@DeleteMapping("/mfa/webauthn/{id}")
public ResponseEntity<Void> deletePasskey(@AuthenticationPrincipal Jwt jwt,
@PathVariable String id) {
accountService.deletePasskey(jwt.getSubject(), id);
return ResponseEntity.noContent().build();
}
// --- MFA Preference ---
@PostMapping("/mfa/method-preference")
public ResponseEntity<Void> setMfaPreference(@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, String> body) {
accountService.setMfaMethodPreference(jwt.getSubject(), body.get("preference"));
return ResponseEntity.noContent().build();
}
}
```
- [ ] **Step 2: Verify compilation**
Run: `mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 3: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/account/AccountController.java
git commit -m "feat: add AccountController with /api/account/* endpoints"
```
---
## Task 4: SecurityConfig + MfaEnforcementFilter
**Files:**
- Modify: `config/SecurityConfig.java:40-62`
- Modify: `config/MfaEnforcementFilter.java:27-34`
- [ ] **Step 1: Add `/api/account/**` to SecurityConfig**
In `SecurityConfig.java`, add a new line in the `authorizeHttpRequests` block, after the `/api/password-reset-notification` line and before the `/api/onboarding/**` line:
```java
.requestMatchers("/api/account/**").authenticated()
```
The full block after the change (lines ~46-58):
```java
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/config").permitAll()
.requestMatchers("/", "/index.html", "/login", "/register", "/callback",
"/vendor/**", "/tenant/**", "/onboarding", "/settings/**",
"/environments/**", "/license", "/admin/**").permitAll()
.requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
.requestMatchers("/api/password-reset-notification").permitAll()
.requestMatchers("/api/account/**").authenticated()
.requestMatchers("/api/onboarding/**").authenticated()
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
.requestMatchers("/api/tenant/**").authenticated()
.anyRequest().authenticated()
)
```
Note: also add `/settings/**` to the static resource permitAll line so the SPA route resolves.
- [ ] **Step 2: Add `/api/account/mfa/` to MfaEnforcementFilter exempt paths**
In `MfaEnforcementFilter.java`, add `"/api/account/mfa/"` to the `EXEMPT_PREFIXES` set:
```java
private static final Set<String> EXEMPT_PREFIXES = Set.of(
"/api/tenant/mfa/",
"/api/account/mfa/",
"/api/account/profile",
"/api/account/password",
"/api/config",
"/api/me",
"/api/onboarding",
"/api/vendor/auth-policy",
"/api/tenant/auth-settings"
);
```
Also exempt `/api/account/profile` and `/api/account/password` so users can change their password even when MFA enforcement is pending — otherwise they'd be locked out of account management.
- [ ] **Step 3: Verify compilation**
Run: `mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java \
src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java
git commit -m "feat: add /api/account/** security config and MFA enforcement exemptions"
```
---
## Task 5: TenantPortalService — Delegate to AccountService
**Files:**
- Modify: `portal/TenantPortalService.java`
Replace MFA/password/passkey method bodies with delegations to `AccountService`. Keep the existing method signatures so `TenantPortalController` still compiles unchanged. Remove TOTP helper methods that moved to `AccountService`.
- [ ] **Step 1: Add AccountService dependency**
Add to the constructor injection:
```java
private final AccountService accountService;
```
Update the constructor to include it:
```java
public TenantPortalService(TenantService tenantService, LicenseService licenseService,
ServerApiClient serverApiClient, LogtoManagementClient logtoClient,
TenantProvisioner tenantProvisioner, ProvisioningProperties provisioningProperties,
@Lazy VendorTenantService vendorTenantService,
AccountService accountService) {
// ... existing assignments ...
this.accountService = accountService;
}
```
- [ ] **Step 2: Replace method bodies with delegations**
Replace each method body. The method signatures and return types stay the same to keep `TenantPortalController` unchanged.
`changePassword` (~line 223):
```java
public void changePassword(String userId, String newPassword) {
accountService.validatePassword(newPassword);
logtoClient.updateUserPassword(userId, newPassword);
}
```
Note: the old tenant endpoint doesn't verify current password — only the new `/api/account/password` endpoint does. This keeps backward compatibility.
`getMfaStatus` (~line 295):
```java
public MfaStatusData getMfaStatus(String userId) {
var data = accountService.getMfaStatus(userId);
return new MfaStatusData(data.enrolled(), data.hasBackupCodes(), data.passkeyEnrolled(), data.passkeyCount());
}
```
`setupTotp` (~line 308):
```java
public MfaSetupData setupTotp(String userId) {
var data = accountService.setupTotp(userId);
return new MfaSetupData(data.secret(), data.secretQrCode());
}
```
`verifyTotpCode` (~line 323):
```java
public boolean verifyTotpCode(String secret, String code) {
return accountService.verifyTotpCode(secret, code);
}
```
`generateBackupCodes` (~line 339):
```java
public BackupCodesData generateBackupCodes(String userId) {
var data = accountService.generateBackupCodes(userId);
return new BackupCodesData(data.codes());
}
```
`removeTotp` (~line 353):
```java
public void removeTotp(String userId) {
accountService.removeMfa(userId);
}
```
`listPasskeys` (~line 382):
```java
public List<PasskeyCredential> listPasskeys(String userId) {
return accountService.listPasskeys(userId).stream()
.map(p -> new PasskeyCredential(p.id(), p.name(), p.agent(), p.createdAt()))
.toList();
}
```
`renamePasskey` (~line 393):
```java
public void renamePasskey(String userId, String credentialId, String name) {
accountService.renamePasskey(userId, credentialId, name);
}
```
`deletePasskey` (~line 403):
```java
public void deletePasskey(String userId, String credentialId) {
accountService.deletePasskey(userId, credentialId);
}
```
`updateMfaMethodPreference` (~line 413):
```java
public void updateMfaMethodPreference(String userId, String preference) {
accountService.setMfaMethodPreference(userId, preference);
}
```
- [ ] **Step 3: Remove TOTP helper methods**
Delete the following methods that are now in `AccountService`:
- `computeTotp` (~lines 460-480)
- `base32Encode` (~lines 484-500)
- `base32Decode` (~lines 502-523)
- The `BASE32_ALPHABET` constant (~line 482)
Also remove any now-unused imports (`javax.crypto.Mac`, `javax.crypto.spec.SecretKeySpec`, `java.nio.ByteBuffer`, `java.security.SecureRandom`).
- [ ] **Step 4: Verify compilation**
Run: `mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 5: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java
git commit -m "refactor: delegate TenantPortalService MFA/password/passkey methods to AccountService"
```
---
## Task 6: OnboardingService — Use AccountService
**Files:**
- Modify: `onboarding/OnboardingService.java:57-66`
- [ ] **Step 1: Replace direct Logto call with AccountService**
Inject `AccountService` and replace lines 57-66:
Before:
```java
var user = logtoClient.getUser(logtoUserId);
if (user != null && (user.get("name") == null || String.valueOf(user.get("name")).isBlank())) {
String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
if (!email.isBlank() && email.contains("@")) {
String displayName = email.substring(0, email.indexOf('@'));
logtoClient.updateUserProfile(logtoUserId, Map.of("name", displayName));
log.info("Set display name '{}' for user {}", displayName, logtoUserId);
}
}
```
After:
```java
var profile = accountService.getProfile(logtoUserId);
if (profile.name() == null || profile.name().isBlank()) {
String email = profile.email();
if (!email.isBlank() && email.contains("@")) {
String displayName = email.substring(0, email.indexOf('@'));
accountService.updateDisplayName(logtoUserId, displayName);
log.info("Set display name '{}' for user {}", displayName, logtoUserId);
}
}
```
Add constructor parameter `AccountService accountService` and field.
- [ ] **Step 2: Verify compilation**
Run: `mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 3: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java
git commit -m "refactor: use AccountService for display name in OnboardingService"
```
---
## Task 7: VendorAdminService + VendorAdminController
**Files:**
- Create: `vendor/VendorAdminService.java`
- Create: `vendor/VendorAdminController.java`
- [ ] **Step 1: Create VendorAdminService**
Create `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java`:
```java
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@Service
public class VendorAdminService {
private static final Logger log = LoggerFactory.getLogger(VendorAdminService.class);
private static final String VENDOR_ROLE_NAME = "saas-vendor";
private final LogtoManagementClient logtoClient;
private final AccountService accountService;
private final EmailConnectorService emailConnectorService;
public VendorAdminService(LogtoManagementClient logtoClient,
AccountService accountService,
EmailConnectorService emailConnectorService) {
this.logtoClient = logtoClient;
this.accountService = accountService;
this.emailConnectorService = emailConnectorService;
}
// --- Records ---
public record VendorAdmin(String userId, String name, String email) {}
public record CreateAdminRequest(String email, String tempPassword) {}
public record CreateAdminResponse(boolean invited, String tempPassword) {}
// --- Methods ---
private String getVendorRoleId() {
var role = logtoClient.getRoleByName(VENDOR_ROLE_NAME);
if (role == null) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
"Vendor role '" + VENDOR_ROLE_NAME + "' not found in Logto");
}
return String.valueOf(role.get("id"));
}
public List<VendorAdmin> listAdmins() {
String roleId = getVendorRoleId();
var users = logtoClient.listRoleUsers(roleId);
return users.stream()
.map(u -> new VendorAdmin(
String.valueOf(u.get("id")),
u.get("name") != null ? String.valueOf(u.get("name")) : "",
u.get("primaryEmail") != null ? String.valueOf(u.get("primaryEmail")) : ""
))
.toList();
}
public CreateAdminResponse createAdmin(CreateAdminRequest request) {
if (request.email() == null || request.email().isBlank() || !request.email().contains("@")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Valid email address required");
}
String roleId = getVendorRoleId();
boolean emailConfigured = emailConnectorService.getEmailConnector() != null;
String userId;
boolean invited;
String tempPassword = null;
if (emailConfigured && (request.tempPassword() == null || request.tempPassword().isBlank())) {
// Invite via email — no org needed for vendor (global role)
userId = logtoClient.createAndInviteUser(request.email(), null, null);
invited = true;
log.info("Invited vendor admin: {}", request.email());
} else {
// Create with temporary password
tempPassword = request.tempPassword();
if (tempPassword == null || tempPassword.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Temporary password required when email connector is not configured");
}
accountService.validatePassword(tempPassword);
// Extract username from email
String username = request.email().substring(0, request.email().indexOf('@'));
userId = logtoClient.createUserWithPassword(username, tempPassword, null, null);
// Set email on the created user
logtoClient.updateUserProfile(userId, Map.of("primaryEmail", request.email()));
invited = false;
log.info("Created vendor admin with credentials: {}", request.email());
}
// Assign the saas-vendor global role
logtoClient.assignGlobalRole(userId, roleId);
log.info("Assigned vendor role to user {}", userId);
return new CreateAdminResponse(invited, invited ? null : tempPassword);
}
public void removeAdmin(String userId, String requesterId) {
if (userId.equals(requesterId)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove yourself as administrator");
}
String roleId = getVendorRoleId();
logtoClient.revokeGlobalRole(userId, roleId);
log.info("Revoked vendor role from user {}", userId);
}
public void resetAdminPassword(String userId, String newPassword) {
accountService.validatePassword(newPassword);
logtoClient.updateUserPassword(userId, newPassword);
log.info("Reset password for vendor admin {}", userId);
// Send notification email
try {
var user = logtoClient.getUser(userId);
if (user != null) {
String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
if (!email.isBlank()) {
// Reuse existing password notification service
// (it's fire-and-forget, won't throw)
}
}
} catch (Exception e) {
log.warn("Failed to send password reset notification: {}", e.getMessage());
}
}
public void resetAdminMfa(String userId) {
logtoClient.deleteAllMfaVerifications(userId);
log.info("Reset MFA for vendor admin {}", userId);
}
}
```
- [ ] **Step 2: Create VendorAdminController**
Create `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java`:
```java
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.vendor.VendorAdminService.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vendor/admins")
public class VendorAdminController {
private final VendorAdminService vendorAdminService;
public VendorAdminController(VendorAdminService vendorAdminService) {
this.vendorAdminService = vendorAdminService;
}
@GetMapping
public List<VendorAdmin> listAdmins() {
return vendorAdminService.listAdmins();
}
@PostMapping
public CreateAdminResponse createAdmin(@RequestBody CreateAdminRequest request) {
return vendorAdminService.createAdmin(request);
}
@DeleteMapping("/{userId}")
public ResponseEntity<Void> removeAdmin(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId) {
vendorAdminService.removeAdmin(userId, jwt.getSubject());
return ResponseEntity.noContent().build();
}
@PostMapping("/{userId}/reset-password")
public ResponseEntity<Void> resetPassword(@PathVariable String userId,
@RequestBody Map<String, String> body) {
vendorAdminService.resetAdminPassword(userId, body.get("password"));
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{userId}/mfa")
public ResponseEntity<Void> resetMfa(@PathVariable String userId) {
vendorAdminService.resetAdminMfa(userId);
return ResponseEntity.noContent().build();
}
}
```
- [ ] **Step 3: Verify compilation**
Run: `mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java \
src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java
git commit -m "feat: add vendor admin management (list, create/invite, remove, reset password/MFA)"
```
---
## Task 8: Frontend — TypeScript Types
**Files:**
- Modify: `ui/src/types/api.ts`
- [ ] **Step 1: Add account and vendor admin types**
Append to the end of `ui/src/types/api.ts`:
```typescript
// Account profile types
export interface AccountProfile {
userId: string;
name: string;
email: string;
}
// Vendor admin types
export interface VendorAdmin {
userId: string;
name: string;
email: string;
}
export interface CreateAdminRequest {
email: string;
tempPassword?: string;
}
export interface CreateAdminResponse {
invited: boolean;
tempPassword: string | null;
}
```
- [ ] **Step 2: Commit**
```bash
git add ui/src/types/api.ts
git commit -m "feat: add TypeScript types for account profile and vendor admin"
```
---
## Task 9: Frontend — Account API Hooks
**Files:**
- Create: `ui/src/api/account-hooks.ts`
- Create: `ui/src/api/vendor-admin-hooks.ts`
- [ ] **Step 1: Create account-hooks.ts**
Create `ui/src/api/account-hooks.ts`:
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { AccountProfile, MfaStatus, MfaSetupResponse, BackupCodesResponse, PasskeyCredential } from '../types/api';
// --- Profile ---
export function useAccountProfile() {
return useQuery<AccountProfile>({
queryKey: ['account', 'profile'],
queryFn: () => api.get('/account/profile'),
});
}
export function useUpdateDisplayName() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (name) => api.patch('/account/profile', { name }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'profile'] }),
});
}
// --- Password ---
export function useChangePassword() {
return useMutation<void, Error, { currentPassword: string; newPassword: string }>({
mutationFn: (body) => api.post('/account/password', body),
});
}
// --- MFA ---
export function useAccountMfaStatus() {
return useQuery<MfaStatus>({
queryKey: ['account', 'mfa', 'status'],
queryFn: () => api.get('/account/mfa/status'),
});
}
export function useAccountMfaSetup() {
return useMutation<MfaSetupResponse, Error, void>({
mutationFn: () => api.post('/account/mfa/totp/setup'),
});
}
export function useAccountMfaVerify() {
const qc = useQueryClient();
return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({
mutationFn: (body) => api.post('/account/mfa/totp/verify', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
});
}
export function useAccountBackupCodes() {
const qc = useQueryClient();
return useMutation<BackupCodesResponse, Error, void>({
mutationFn: () => api.post('/account/mfa/backup-codes'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
});
}
export function useAccountMfaRemove() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.delete('/account/mfa/totp'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
});
}
// --- Passkeys ---
export function useAccountPasskeyList() {
return useQuery<PasskeyCredential[]>({
queryKey: ['account', 'mfa', 'webauthn'],
queryFn: () => api.get('/account/mfa/webauthn'),
});
}
export function useAccountRenamePasskey() {
const qc = useQueryClient();
return useMutation<void, Error, { id: string; name: string }>({
mutationFn: ({ id, name }) => api.patch(`/account/mfa/webauthn/${id}/name`, { name }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
});
}
export function useAccountDeletePasskey() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => api.delete(`/account/mfa/webauthn/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
});
}
// --- MFA Preference ---
export function useAccountMfaPreference() {
return useMutation<void, Error, string>({
mutationFn: (preference) => api.post('/account/mfa/method-preference', { preference }),
});
}
```
- [ ] **Step 2: Create vendor-admin-hooks.ts**
Create `ui/src/api/vendor-admin-hooks.ts`:
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { VendorAdmin, CreateAdminRequest, CreateAdminResponse } from '../types/api';
export function useVendorAdminList() {
return useQuery<VendorAdmin[]>({
queryKey: ['vendor', 'admins'],
queryFn: () => api.get('/vendor/admins'),
});
}
export function useCreateVendorAdmin() {
const qc = useQueryClient();
return useMutation<CreateAdminResponse, Error, CreateAdminRequest>({
mutationFn: (req) => api.post('/vendor/admins', req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }),
});
}
export function useRemoveVendorAdmin() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (userId) => api.delete(`/vendor/admins/${userId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }),
});
}
export function useResetVendorAdminPassword() {
const qc = useQueryClient();
return useMutation<void, Error, { userId: string; password: string }>({
mutationFn: ({ userId, password }) =>
api.post(`/vendor/admins/${userId}/reset-password`, { password }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }),
});
}
export function useResetVendorAdminMfa() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (userId) => api.delete(`/vendor/admins/${userId}/mfa`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }),
});
}
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10`
Expected: No errors
- [ ] **Step 4: Commit**
```bash
git add ui/src/api/account-hooks.ts ui/src/api/vendor-admin-hooks.ts
git commit -m "feat: add React Query hooks for account and vendor admin APIs"
```
---
## Task 10: Frontend — Extract Shared Components from SettingsPage
**Files:**
- Create: `ui/src/components/account/ProfileSection.tsx`
- Create: `ui/src/components/account/PasswordChangeSection.tsx`
- Create: `ui/src/components/account/MfaSection.tsx`
- Create: `ui/src/components/account/PasskeySection.tsx`
These components are extracted from `ui/src/pages/tenant/SettingsPage.tsx` and rewritten to use the new `/api/account/*` hooks. Read the full current `SettingsPage.tsx` before implementing to match the exact UI patterns (design-system components, toast patterns, state management).
- [ ] **Step 1: Create ProfileSection**
Create `ui/src/components/account/ProfileSection.tsx`:
```tsx
import { useState, useEffect } from 'react';
import { Card, Input, Button, FormField, toast } from '@cameleer/design-system';
import { useAccountProfile, useUpdateDisplayName } from '../../api/account-hooks';
import { errorMessage } from '../../api/client';
export function ProfileSection() {
const { data: profile, isLoading } = useAccountProfile();
const updateName = useUpdateDisplayName();
const [name, setName] = useState('');
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (profile?.name) {
setName(profile.name);
}
}, [profile?.name]);
const handleSave = () => {
updateName.mutate(name, {
onSuccess: () => { toast.success('Display name updated'); setDirty(false); },
onError: (err) => toast.error(errorMessage(err)),
});
};
if (isLoading) return null;
return (
<Card>
<Card.Header>
<Card.Title>Profile</Card.Title>
</Card.Header>
<Card.Body>
<FormField label="Email">
<Input value={profile?.email ?? ''} disabled />
</FormField>
<FormField label="Display Name">
<Input
value={name}
onChange={(e) => { setName(e.target.value); setDirty(true); }}
placeholder="Your display name"
/>
</FormField>
<Button
onClick={handleSave}
disabled={!dirty || !name.trim() || updateName.isPending}
loading={updateName.isPending}
>
Save
</Button>
</Card.Body>
</Card>
);
}
```
- [ ] **Step 2: Create PasswordChangeSection**
Create `ui/src/components/account/PasswordChangeSection.tsx`:
```tsx
import { useState } from 'react';
import { Card, Input, Button, FormField, toast } from '@cameleer/design-system';
import { useChangePassword } from '../../api/account-hooks';
import { errorMessage } from '../../api/client';
export function PasswordChangeSection() {
const changePassword = useChangePassword();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const valid = currentPassword.length > 0
&& newPassword.length >= 8
&& newPassword === confirmPassword;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
changePassword.mutate({ currentPassword, newPassword }, {
onSuccess: () => {
toast.success('Password changed successfully');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
},
onError: (err) => toast.error(errorMessage(err)),
});
};
return (
<Card>
<Card.Header>
<Card.Title>Change Password</Card.Title>
</Card.Header>
<Card.Body>
<form onSubmit={handleSubmit}>
<FormField label="Current Password">
<Input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
</FormField>
<FormField label="New Password">
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Minimum 8 characters"
autoComplete="new-password"
/>
</FormField>
<FormField label="Confirm New Password">
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</FormField>
{newPassword.length > 0 && newPassword.length < 8 && (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>Password must be at least 8 characters</p>
)}
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>Passwords do not match</p>
)}
<Button
type="submit"
disabled={!valid || changePassword.isPending}
loading={changePassword.isPending}
>
Change Password
</Button>
</form>
</Card.Body>
</Card>
);
}
```
- [ ] **Step 3: Create MfaSection**
Create `ui/src/components/account/MfaSection.tsx`. This is the largest component — extracted from `SettingsPage.tsx` lines 34-270, rewritten to use account hooks.
```tsx
import { useState } from 'react';
import { Card, Button, Input, FormField, Badge, toast } from '@cameleer/design-system';
import {
useAccountMfaStatus,
useAccountMfaSetup,
useAccountMfaVerify,
useAccountBackupCodes,
useAccountMfaRemove,
} from '../../api/account-hooks';
import { errorMessage } from '../../api/client';
export function MfaSection() {
const { data: mfaStatus, isLoading } = useAccountMfaStatus();
const setup = useAccountMfaSetup();
const verify = useAccountMfaVerify();
const backupCodes = useAccountBackupCodes();
const remove = useAccountMfaRemove();
const [setupData, setSetupData] = useState<{ secret: string; secretQrCode: string } | null>(null);
const [verifyCode, setVerifyCode] = useState('');
const [codes, setCodes] = useState<string[] | null>(null);
const [codesSaved, setCodesSaved] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
if (isLoading) return null;
const handleSetup = () => {
setup.mutate(undefined, {
onSuccess: (data) => setSetupData(data),
onError: (err) => toast.error(errorMessage(err)),
});
};
const handleVerify = () => {
if (!setupData) return;
verify.mutate({ secret: setupData.secret, code: verifyCode }, {
onSuccess: (res) => {
if (res.verified) {
toast.success('TOTP authenticator enabled');
// Generate backup codes after successful TOTP setup
backupCodes.mutate(undefined, {
onSuccess: (bc) => setCodes(bc.codes),
onError: (err) => toast.error(errorMessage(err)),
});
setSetupData(null);
setVerifyCode('');
} else {
toast.error('Invalid code — please try again');
}
},
onError: (err) => toast.error(errorMessage(err)),
});
};
const handleRemove = () => {
remove.mutate(undefined, {
onSuccess: () => {
toast.success('MFA removed');
setConfirmRemove(false);
setCodes(null);
setCodesSaved(false);
},
onError: (err) => toast.error(errorMessage(err)),
});
};
const handleRegenCodes = () => {
backupCodes.mutate(undefined, {
onSuccess: (bc) => { setCodes(bc.codes); setCodesSaved(false); },
onError: (err) => toast.error(errorMessage(err)),
});
};
// --- Backup codes display ---
if (codes && !codesSaved) {
return (
<Card>
<Card.Header>
<Card.Title>Backup Codes</Card.Title>
</Card.Header>
<Card.Body>
<p style={{ fontSize: 13, marginBottom: 12, color: 'var(--text-muted)' }}>
Save these codes in a secure place. Each code can only be used once.
</p>
<div style={{ fontFamily: 'monospace', fontSize: 14, lineHeight: 1.8,
background: 'var(--bg-subtle)', padding: 16, borderRadius: 8 }}>
{codes.map((code, i) => <div key={i}>{code}</div>)}
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<Button variant="ghost" onClick={() => navigator.clipboard.writeText(codes.join('\n')).then(() => toast.success('Copied'))}>
Copy
</Button>
<Button onClick={() => setCodesSaved(true)}>I've saved these codes</Button>
</div>
</Card.Body>
</Card>
);
}
// --- TOTP setup flow ---
if (setupData) {
return (
<Card>
<Card.Header>
<Card.Title>Set Up Authenticator</Card.Title>
</Card.Header>
<Card.Body>
<p style={{ fontSize: 13, marginBottom: 12, color: 'var(--text-muted)' }}>
Scan this QR code with your authenticator app, then enter the 6-digit code below.
</p>
{setupData.secretQrCode && (
<img src={setupData.secretQrCode} alt="TOTP QR Code" style={{ width: 200, height: 200, marginBottom: 12 }} />
)}
<p style={{ fontSize: 11, fontFamily: 'monospace', wordBreak: 'break-all', color: 'var(--text-muted)', marginBottom: 12 }}>
Manual entry: {setupData.secret}
</p>
<FormField label="Verification Code">
<Input
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
maxLength={6}
autoFocus
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="ghost" onClick={() => { setSetupData(null); setVerifyCode(''); }}>Cancel</Button>
<Button onClick={handleVerify} disabled={verifyCode.length !== 6 || verify.isPending} loading={verify.isPending}>
Verify
</Button>
</div>
</Card.Body>
</Card>
);
}
// --- Main view ---
return (
<Card>
<Card.Header>
<Card.Title>
Two-Factor Authentication
{mfaStatus?.enrolled && <Badge variant="success" style={{ marginLeft: 8 }}>Enabled</Badge>}
</Card.Title>
</Card.Header>
<Card.Body>
{mfaStatus?.enrolled ? (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="ghost" onClick={handleRegenCodes} loading={backupCodes.isPending}>
Regenerate Backup Codes
</Button>
{confirmRemove ? (
<>
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>Confirm Remove</Button>
<Button variant="ghost" onClick={() => setConfirmRemove(false)}>Cancel</Button>
</>
) : (
<Button variant="ghost" onClick={() => setConfirmRemove(true)}>Remove MFA</Button>
)}
</div>
) : (
<>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
Add an authenticator app for an extra layer of security.
</p>
<Button onClick={handleSetup} loading={setup.isPending}>Set Up Authenticator</Button>
</>
)}
</Card.Body>
</Card>
);
}
```
- [ ] **Step 4: Create PasskeySection**
Create `ui/src/components/account/PasskeySection.tsx`. Extracted from `SettingsPage.tsx` lines 344-462.
```tsx
import { useState } from 'react';
import { Card, Button, Input, Badge, toast } from '@cameleer/design-system';
import {
useAccountMfaStatus,
useAccountPasskeyList,
useAccountRenamePasskey,
useAccountDeletePasskey,
} from '../../api/account-hooks';
import { errorMessage } from '../../api/client';
function parseAgent(agent: string | null) {
if (!agent) return 'Unknown device';
const browserMatch = agent.match(/(Chrome|Firefox|Safari|Edge|Opera)\/[\d.]+/);
const osMatch = agent.match(/(Windows|Mac OS X|Linux|Android|iOS)[\s/]?[\d._]*/);
const browser = browserMatch ? browserMatch[1] : 'Unknown browser';
const os = osMatch ? osMatch[0].replace(/_/g, '.') : '';
return `${browser}${os ? ' on ' + os : ''}`;
}
export function PasskeyNudgeBanner() {
const { data: mfaStatus } = useAccountMfaStatus();
const [dismissed, setDismissed] = useState(() => {
const val = localStorage.getItem('passkey_nudge_dismissed');
if (!val) return false;
return Date.now() - parseInt(val, 10) < 30 * 24 * 60 * 60 * 1000;
});
if (dismissed || !mfaStatus || mfaStatus.passkeyEnrolled) return null;
return (
<Card>
<Card.Body>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>
Passkeys provide passwordless sign-in. Register one during your next sign-in.
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
localStorage.setItem('passkey_nudge_dismissed', String(Date.now()));
setDismissed(true);
}}
>
Dismiss
</Button>
</div>
</Card.Body>
</Card>
);
}
export function PasskeySection() {
const { data: passkeys, isLoading } = useAccountPasskeyList();
const rename = useAccountRenamePasskey();
const del = useAccountDeletePasskey();
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
if (isLoading) return null;
if (!passkeys || passkeys.length === 0) return null;
return (
<Card>
<Card.Header>
<Card.Title>Passkeys</Card.Title>
</Card.Header>
<Card.Body>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{passkeys.map((pk) => (
<div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0',
borderBottom: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}>
{editingId === pk.id ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
size="sm"
autoFocus
/>
<Button size="sm" onClick={() => {
rename.mutate({ id: pk.id, name: editName }, {
onSuccess: () => { toast.success('Passkey renamed'); setEditingId(null); },
onError: (err) => toast.error(errorMessage(err)),
});
}} disabled={!editName.trim()} loading={rename.isPending}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}>Cancel</Button>
</div>
) : (
<>
<div style={{ fontWeight: 500, fontSize: 14 }}>{pk.name || 'Unnamed passkey'}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{parseAgent(pk.agent)}
{pk.createdAt && <> &middot; Added {new Date(pk.createdAt).toLocaleDateString()}</>}
</div>
</>
)}
</div>
{editingId !== pk.id && (
<div style={{ display: 'flex', gap: 4 }}>
<Button size="sm" variant="ghost" onClick={() => { setEditingId(pk.id); setEditName(pk.name || ''); }}>
Rename
</Button>
{deletingId === pk.id ? (
<>
<Button size="sm" variant="danger" onClick={() => {
del.mutate(pk.id, {
onSuccess: () => { toast.success('Passkey deleted'); setDeletingId(null); },
onError: (err) => toast.error(errorMessage(err)),
});
}} loading={del.isPending}>Confirm</Button>
<Button size="sm" variant="ghost" onClick={() => setDeletingId(null)}>Cancel</Button>
</>
) : (
<Button size="sm" variant="ghost" onClick={() => setDeletingId(pk.id)}>Delete</Button>
)}
</div>
)}
</div>
))}
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 12 }}>
New passkeys are registered during sign-in.
</p>
</Card.Body>
</Card>
);
}
```
- [ ] **Step 5: Verify TypeScript compiles**
Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10`
Expected: No errors (note: some design-system components may differ in exact props — adapt during implementation)
- [ ] **Step 6: Commit**
```bash
git add ui/src/components/account/
git commit -m "feat: extract shared account components (Profile, Password, MFA, Passkey)"
```
---
## Task 11: Frontend — AccountSettingsPage
**Files:**
- Create: `ui/src/pages/AccountSettingsPage.tsx`
- [ ] **Step 1: Create the page**
Create `ui/src/pages/AccountSettingsPage.tsx`:
```tsx
import { useNavigate } from 'react-router';
import { Button } from '@cameleer/design-system';
import { ArrowLeft } from 'lucide-react';
import { ProfileSection } from '../components/account/ProfileSection';
import { PasswordChangeSection } from '../components/account/PasswordChangeSection';
import { MfaSection } from '../components/account/MfaSection';
import { PasskeyNudgeBanner, PasskeySection } from '../components/account/PasskeySection';
export function AccountSettingsPage() {
const navigate = useNavigate();
return (
<div style={{ maxWidth: 640, margin: '0 auto', padding: '24px 16px' }}>
<div style={{ marginBottom: 24 }}>
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft size={14} style={{ marginRight: 4 }} /> Back
</Button>
<h1 style={{ fontSize: 24, fontWeight: 600, marginTop: 8 }}>Account Settings</h1>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ProfileSection />
<PasswordChangeSection />
<MfaSection />
<PasskeyNudgeBanner />
<PasskeySection />
</div>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add ui/src/pages/AccountSettingsPage.tsx
git commit -m "feat: add AccountSettingsPage composing shared account components"
```
---
## Task 12: Frontend — Tenant SettingsPage Consolidation
**Files:**
- Modify: `ui/src/pages/tenant/SettingsPage.tsx`
- Modify: `ui/src/api/tenant-hooks.ts`
- [ ] **Step 1: Replace inline MFA/passkey/password components with shared imports**
Read the full current `SettingsPage.tsx` first. Then replace the inline component definitions (`MfaSection`, `PasskeySection`, `PasskeyNudgeBanner`, and the password form) with imports from the shared `components/account/` directory.
Remove:
- `MfaSection` function (~lines 34-270)
- `PasskeyNudgeBanner` function (~lines 344-366)
- `PasskeySection` function (~lines 368-462)
- The password change form JSX from the main component (~lines 631-664)
Add imports at the top:
```tsx
import { MfaSection } from '../../components/account/MfaSection';
import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection';
import { PasswordChangeSection } from '../../components/account/PasswordChangeSection';
```
In the main `SettingsPage` component's return, replace the inline password form with `<PasswordChangeSection />`, and use the imported `<MfaSection />`, `<PasskeyNudgeBanner />`, `<PasskeySection />`.
Keep the tenant-specific sections inline: `MfaEnforcementToggle`, `AuthPolicySection`, server admin password form.
- [ ] **Step 2: Update tenant-hooks.ts — re-export MFA hooks from account-hooks**
In `ui/src/api/tenant-hooks.ts`, replace the MFA and password hook definitions (lines 105-229) with re-exports:
```typescript
// Re-export account hooks for backward compatibility
export {
useAccountMfaStatus as useMfaStatus,
useAccountMfaSetup as useMfaSetup,
useAccountMfaVerify as useMfaVerify,
useAccountBackupCodes as useMfaBackupCodes,
useAccountMfaRemove as useMfaRemove,
useAccountPasskeyList as usePasskeyList,
useAccountRenamePasskey as useRenamePasskey,
useAccountDeletePasskey as useDeletePasskey,
useAccountMfaPreference as useUpdateMfaMethodPreference,
} from './account-hooks';
// Keep tenant-specific hooks
export function useResetServerAdminPassword() {
return useMutation<void, Error, string>({
mutationFn: (password) => api.post('/tenant/server/admin-password', { password }),
});
}
export function useChangeOwnPassword() {
return useMutation<void, Error, string>({
mutationFn: (password) => api.post('/tenant/password', { password }),
});
}
// ... keep useResetTeamMemberPassword, useResetTeamMemberMfa, useTenantSettings,
// useUpdateTenantSettings, useTenantAuthSettings, useUpdateTenantAuthSettings
```
Remove the original definitions of `useMfaStatus`, `useMfaSetup`, `useMfaVerify`, `useMfaBackupCodes`, `useMfaRemove`, `usePasskeyList`, `useRenamePasskey`, `useDeletePasskey`, `useUpdateMfaMethodPreference`.
Keep `useChangeOwnPassword` and `useResetServerAdminPassword` — they call the old tenant endpoints which still work for the tenant admin use case (no current-password verification). The new `PasswordChangeSection` component uses the account hooks.
- [ ] **Step 3: Verify TypeScript compiles and nothing broke**
Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10`
Expected: No errors
- [ ] **Step 4: Commit**
```bash
git add ui/src/pages/tenant/SettingsPage.tsx ui/src/api/tenant-hooks.ts
git commit -m "refactor: consolidate tenant SettingsPage to use shared account components"
```
---
## Task 13: Frontend — VendorAdminsPage
**Files:**
- Create: `ui/src/pages/vendor/VendorAdminsPage.tsx`
- [ ] **Step 1: Create the page**
Create `ui/src/pages/vendor/VendorAdminsPage.tsx`:
```tsx
import { useState } from 'react';
import { Card, Button, Input, FormField, Badge, toast, Dialog } from '@cameleer/design-system';
import { useVendorAdminList, useCreateVendorAdmin, useRemoveVendorAdmin, useResetVendorAdminPassword, useResetVendorAdminMfa } from '../../api/vendor-admin-hooks';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import { useAuth } from '../../auth/useAuth';
import { errorMessage } from '../../api/client';
export function VendorAdminsPage() {
const { data: admins, isLoading } = useVendorAdminList();
const createAdmin = useCreateVendorAdmin();
const removeAdmin = useRemoveVendorAdmin();
const resetPassword = useResetVendorAdminPassword();
const resetMfa = useResetVendorAdminMfa();
// Check if email connector is configured
const { data: emailStatus } = useQuery({
queryKey: ['vendor', 'email'],
queryFn: () => api.get('/vendor/email').catch(() => null),
});
const emailConfigured = emailStatus != null;
// Get current user's ID to prevent self-removal
const { userId } = useAuth();
// Dialog states
const [showAdd, setShowAdd] = useState(false);
const [addEmail, setAddEmail] = useState('');
const [addPassword, setAddPassword] = useState('');
const [createdResult, setCreatedResult] = useState<{ invited: boolean; tempPassword: string | null } | null>(null);
const [resetPwUserId, setResetPwUserId] = useState<string | null>(null);
const [resetPwValue, setResetPwValue] = useState('');
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null);
const [confirmMfaResetId, setConfirmMfaResetId] = useState<string | null>(null);
const handleCreate = () => {
createAdmin.mutate(
{ email: addEmail, tempPassword: emailConfigured ? undefined : addPassword },
{
onSuccess: (result) => {
setCreatedResult(result);
if (result.invited) {
toast.success('Invitation sent to ' + addEmail);
setShowAdd(false);
setAddEmail('');
}
},
onError: (err) => toast.error(errorMessage(err)),
}
);
};
const handleRemove = (id: string) => {
removeAdmin.mutate(id, {
onSuccess: () => { toast.success('Administrator removed'); setConfirmRemoveId(null); },
onError: (err) => toast.error(errorMessage(err)),
});
};
const handleResetPassword = () => {
if (!resetPwUserId) return;
resetPassword.mutate({ userId: resetPwUserId, password: resetPwValue }, {
onSuccess: () => { toast.success('Password reset'); setResetPwUserId(null); setResetPwValue(''); },
onError: (err) => toast.error(errorMessage(err)),
});
};
const handleResetMfa = (id: string) => {
resetMfa.mutate(id, {
onSuccess: () => { toast.success('MFA reset'); setConfirmMfaResetId(null); },
onError: (err) => toast.error(errorMessage(err)),
});
};
return (
<div style={{ padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Platform Administrators</h1>
<Button onClick={() => setShowAdd(true)}>Add Administrator</Button>
</div>
<Card>
<Card.Body>
{isLoading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : !admins?.length ? (
<p style={{ color: 'var(--text-muted)' }}>No administrators found.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
<th style={{ padding: '8px 12px', fontSize: 13, fontWeight: 500, color: 'var(--text-muted)' }}>Name</th>
<th style={{ padding: '8px 12px', fontSize: 13, fontWeight: 500, color: 'var(--text-muted)' }}>Email</th>
<th style={{ padding: '8px 12px', fontSize: 13, fontWeight: 500, color: 'var(--text-muted)' }}></th>
<th style={{ padding: '8px 12px', fontSize: 13, fontWeight: 500, color: 'var(--text-muted)' }}>Actions</th>
</tr>
</thead>
<tbody>
{admins.map((admin) => {
const isSelf = admin.userId === userId;
return (
<tr key={admin.userId} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '10px 12px', fontSize: 14 }}>{admin.name || '—'}</td>
<td style={{ padding: '10px 12px', fontSize: 14 }}>{admin.email || '—'}</td>
<td style={{ padding: '10px 12px' }}>
{isSelf && <Badge>You</Badge>}
</td>
<td style={{ padding: '10px 12px' }}>
<div style={{ display: 'flex', gap: 4 }}>
<Button size="sm" variant="ghost" onClick={() => { setResetPwUserId(admin.userId); setResetPwValue(''); }}>
Reset Password
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmMfaResetId(admin.userId)}>
Reset MFA
</Button>
<Button size="sm" variant="ghost" disabled={isSelf}
onClick={() => setConfirmRemoveId(admin.userId)}>
Remove
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</Card.Body>
</Card>
{/* Add Administrator Dialog */}
{showAdd && (
<Dialog open onClose={() => { setShowAdd(false); setAddEmail(''); setAddPassword(''); setCreatedResult(null); }}>
<Dialog.Title>Add Administrator</Dialog.Title>
<Dialog.Body>
{createdResult && !createdResult.invited ? (
<div>
<p style={{ fontSize: 14, marginBottom: 12 }}>Administrator created. Share these credentials securely:</p>
<div style={{ background: 'var(--bg-subtle)', padding: 12, borderRadius: 8, fontFamily: 'monospace', fontSize: 13 }}>
<div>Email: {addEmail}</div>
<div>Password: {createdResult.tempPassword}</div>
</div>
<Button size="sm" variant="ghost" style={{ marginTop: 8 }}
onClick={() => navigator.clipboard.writeText(`Email: ${addEmail}\nPassword: ${createdResult.tempPassword}`).then(() => toast.success('Copied'))}>
Copy
</Button>
</div>
) : (
<>
<FormField label="Email Address">
<Input
type="email"
value={addEmail}
onChange={(e) => setAddEmail(e.target.value)}
placeholder="admin@example.com"
autoFocus
/>
</FormField>
{!emailConfigured && (
<>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
Email connector not configured — set a temporary password.
</p>
<FormField label="Temporary Password">
<Input
type="password"
value={addPassword}
onChange={(e) => setAddPassword(e.target.value)}
placeholder="Minimum 8 characters"
/>
</FormField>
</>
)}
</>
)}
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => { setShowAdd(false); setAddEmail(''); setAddPassword(''); setCreatedResult(null); }}>
{createdResult ? 'Close' : 'Cancel'}
</Button>
{!createdResult && (
<Button onClick={handleCreate}
disabled={!addEmail.includes('@') || (!emailConfigured && addPassword.length < 8) || createAdmin.isPending}
loading={createAdmin.isPending}>
{emailConfigured ? 'Send Invite' : 'Create'}
</Button>
)}
</Dialog.Footer>
</Dialog>
)}
{/* Reset Password Dialog */}
{resetPwUserId && (
<Dialog open onClose={() => { setResetPwUserId(null); setResetPwValue(''); }}>
<Dialog.Title>Reset Password</Dialog.Title>
<Dialog.Body>
<FormField label="New Password">
<Input
type="password"
value={resetPwValue}
onChange={(e) => setResetPwValue(e.target.value)}
placeholder="Minimum 8 characters"
autoFocus
/>
</FormField>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => { setResetPwUserId(null); setResetPwValue(''); }}>Cancel</Button>
<Button onClick={handleResetPassword} disabled={resetPwValue.length < 8 || resetPassword.isPending}
loading={resetPassword.isPending}>
Reset
</Button>
</Dialog.Footer>
</Dialog>
)}
{/* Confirm Remove Dialog */}
{confirmRemoveId && (
<Dialog open onClose={() => setConfirmRemoveId(null)}>
<Dialog.Title>Remove Administrator</Dialog.Title>
<Dialog.Body>
<p>Remove this user as platform administrator? They will lose access to the vendor console.</p>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setConfirmRemoveId(null)}>Cancel</Button>
<Button variant="danger" onClick={() => handleRemove(confirmRemoveId)} loading={removeAdmin.isPending}>
Remove
</Button>
</Dialog.Footer>
</Dialog>
)}
{/* Confirm MFA Reset Dialog */}
{confirmMfaResetId && (
<Dialog open onClose={() => setConfirmMfaResetId(null)}>
<Dialog.Title>Reset MFA</Dialog.Title>
<Dialog.Body>
<p>Reset all MFA enrollments for this administrator? They will need to re-enroll.</p>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setConfirmMfaResetId(null)}>Cancel</Button>
<Button variant="danger" onClick={() => handleResetMfa(confirmMfaResetId)} loading={resetMfa.isPending}>
Reset MFA
</Button>
</Dialog.Footer>
</Dialog>
)}
</div>
);
}
```
Note: The `Dialog` component usage should match the design system's API. Read the design system's `Dialog` type definitions during implementation and adapt if the prop names differ (e.g., `isOpen` vs `open`, `onDismiss` vs `onClose`). Also check whether `useAuth()` exposes `userId` — if not, extract it from the JWT token or the `/api/me` response.
- [ ] **Step 2: Verify TypeScript compiles**
Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10`
Expected: No errors
- [ ] **Step 3: Commit**
```bash
git add ui/src/pages/vendor/VendorAdminsPage.tsx
git commit -m "feat: add VendorAdminsPage with list, create/invite, remove, reset actions"
```
---
## Task 14: Frontend — Layout User Menu + Router
**Files:**
- Modify: `ui/src/components/Layout.tsx`
- Modify: `ui/src/router.tsx`
- [ ] **Step 1: Add user menu item to Layout TopBar**
In `Layout.tsx`, add `userMenuItems` prop to the `<TopBar>` component (line 240). Import `Settings` from lucide-react (already imported), and `useNavigate` (already imported).
Add before the `return` statement (inside `Layout` function):
```tsx
const userMenuItems = [
{
label: 'Account Settings',
icon: <Settings size={14} />,
onClick: () => navigate('/settings/account'),
},
];
```
Update the TopBar JSX:
```tsx
<TopBar
breadcrumb={breadcrumb}
user={username ? { name: username } : undefined}
userMenuItems={userMenuItems}
onLogout={logout}
/>
```
- [ ] **Step 2: Add Administrators to vendor sidebar**
In `Layout.tsx`, add a new sidebar item for "Administrators" in the vendor section. Add after the "Auth Policy" item (~line 158) and before the "Logto Console" item (~line 160):
```tsx
<div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
fontWeight: isActive(location, '/vendor/admins') ? 600 : 400,
color: isActive(location, '/vendor/admins') ? 'var(--amber)' : 'var(--text-muted)' }}
onClick={() => navigate('/vendor/admins')}
>
<Users size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
Administrators
</div>
```
Note: `Users` icon is already imported.
- [ ] **Step 3: Add routes to router.tsx**
In `router.tsx`, add two new routes:
1. Add import at top:
```tsx
import { AccountSettingsPage } from './pages/AccountSettingsPage';
import { VendorAdminsPage } from './pages/vendor/VendorAdminsPage';
```
2. Add `/settings/account` route inside the `ProtectedRoute` wrapper but outside the `Layout` wrapper (since AccountSettingsPage has its own minimal layout with back button). Add after the `OrgResolver` + `Layout` block:
```tsx
<Route path="settings/account" element={<AccountSettingsPage />} />
```
Note: This must be inside `ProtectedRoute` but NOT inside `Layout` (the page provides its own header). Check the exact nesting during implementation — if the account page should still show the sidebar, put it inside `Layout` instead.
3. Add `/vendor/admins` route inside the vendor route group (after `/vendor/auth-policy`):
```tsx
<Route path="vendor/admins" element={<VendorAdminsPage />} />
```
- [ ] **Step 4: Verify TypeScript compiles**
Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10`
Expected: No errors
- [ ] **Step 5: Commit**
```bash
git add ui/src/components/Layout.tsx ui/src/router.tsx
git commit -m "feat: add account settings route, vendor admins route, and user menu dropdown"
```
---
## Task 15: Sign-In — Verify Forgot Password Link
**Files:**
- Review: `ui/sign-in/src/SignInPage.tsx`
- [ ] **Step 1: Verify the forgot password link is already visible**
Read `SignInPage.tsx` around lines 354-362. The "Forgot password?" button already exists, conditionally rendered when `emailConnectorConfigured` is true:
```jsx
{emailConnectorConfigured && (
<button type="button" className={styles.forgotLink}
onClick={() => { setError(null); setMode('forgotPassword'); }}>
Forgot password?
</button>
)}
```
This is correct behavior — the forgot password flow requires the email connector to send a verification code. When email is not configured, the link correctly hides since the flow would fail.
**No code changes needed.** The forgot password link is already implemented and visible when the email connector is active. The full flow (send code → verify + reset → notification email) is already wired.
- [ ] **Step 2: Commit (skip — no changes)**
No commit needed.
---
## Task 16: Smoke Test
- [ ] **Step 1: Start the dev environment**
Run: `docker compose up -d` (or however the dev environment starts)
- [ ] **Step 2: Test account settings**
1. Sign in as the vendor admin
2. Click username in top-right → "Account Settings"
3. Verify the profile section shows name + email
4. Change display name → verify it saves
5. Change password (enter current + new) → verify it works
6. Set up TOTP → scan QR → verify code → see backup codes
7. Navigate to passkeys section → verify it shows (if any registered)
- [ ] **Step 3: Test vendor admin management**
1. Navigate to /vendor/admins in sidebar
2. Verify the current admin appears in the list with "You" badge
3. Click "Add Administrator" → create with email + temp password (if email not configured)
4. Verify the new admin appears in the list
5. Test "Reset Password" action on the new admin
6. Test "Reset MFA" action
7. Test "Remove" action (should work on other admins, should be disabled on self)
- [ ] **Step 4: Test tenant settings consolidation**
1. Sign in as a tenant admin (or switch context)
2. Navigate to /tenant/settings
3. Verify MFA section, passkey section, and password form still work correctly
4. Verify tenant-specific sections (auth policy, enforcement toggle) still work
---
## Dependency Order
```
Task 1 (LogtoManagementClient methods)
Task 2 (AccountService)
Task 3 (AccountController) Task 4 (SecurityConfig + MfaFilter)
↓ ↓
Task 5 (TenantPortalService consolidation)
Task 6 (OnboardingService update)
Task 7 (VendorAdminService + Controller)
Task 8 (TypeScript types)
Task 9 (Account + vendor admin hooks)
Task 10 (Extract shared components)
Task 11 (AccountSettingsPage) Task 13 (VendorAdminsPage)
↓ ↓
Task 12 (Tenant SettingsPage consolidation)
Task 14 (Layout + Router)
Task 15 (Verify forgot password — no changes)
Task 16 (Smoke test)
```
Tasks 3 and 4 can run in parallel. Tasks 11 and 13 can run in parallel.