16 tasks covering: LogtoManagementClient additions, AccountService extraction, AccountController, VendorAdminService/Controller, SecurityConfig updates, frontend component extraction, shared AccountSettingsPage, VendorAdminsPage, and Layout user menu. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
83 KiB
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
verifyUserPasswordmethod
Add after the existing updateUserPassword method (after line ~527):
/**
* 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):
/**
* 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
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:
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
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:
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
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:
.requestMatchers("/api/account/**").authenticated()
The full block after the change (lines ~46-58):
.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:
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
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:
private final AccountService accountService;
Update the constructor to include it:
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):
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):
public MfaStatusData getMfaStatus(String userId) {
var data = accountService.getMfaStatus(userId);
return new MfaStatusData(data.enrolled(), data.hasBackupCodes(), data.passkeyEnrolled(), data.passkeyCount());
}
setupTotp (~line 308):
public MfaSetupData setupTotp(String userId) {
var data = accountService.setupTotp(userId);
return new MfaSetupData(data.secret(), data.secretQrCode());
}
verifyTotpCode (~line 323):
public boolean verifyTotpCode(String secret, String code) {
return accountService.verifyTotpCode(secret, code);
}
generateBackupCodes (~line 339):
public BackupCodesData generateBackupCodes(String userId) {
var data = accountService.generateBackupCodes(userId);
return new BackupCodesData(data.codes());
}
removeTotp (~line 353):
public void removeTotp(String userId) {
accountService.removeMfa(userId);
}
listPasskeys (~line 382):
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):
public void renamePasskey(String userId, String credentialId, String name) {
accountService.renamePasskey(userId, credentialId, name);
}
deletePasskey (~line 403):
public void deletePasskey(String userId, String credentialId) {
accountService.deletePasskey(userId, credentialId);
}
updateMfaMethodPreference (~line 413):
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_ALPHABETconstant (~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
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:
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:
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
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:
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:
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
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:
// 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
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:
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:
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
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:
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:
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.
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.
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 && <> · 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
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:
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
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:
MfaSectionfunction (~lines 34-270)PasskeyNudgeBannerfunction (~lines 344-366)PasskeySectionfunction (~lines 368-462)- The password change form JSX from the main component (~lines 631-664)
Add imports at the top:
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:
// 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
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:
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
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):
const userMenuItems = [
{
label: 'Account Settings',
icon: <Settings size={14} />,
onClick: () => navigate('/settings/account'),
},
];
Update the TopBar JSX:
<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):
<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:
- Add import at top:
import { AccountSettingsPage } from './pages/AccountSettingsPage';
import { VendorAdminsPage } from './pages/vendor/VendorAdminsPage';
- Add
/settings/accountroute inside theProtectedRoutewrapper but outside theLayoutwrapper (since AccountSettingsPage has its own minimal layout with back button). Add after theOrgResolver+Layoutblock:
<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.
- Add
/vendor/adminsroute inside the vendor route group (after/vendor/auth-policy):
<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
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:
{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
- Sign in as the vendor admin
- Click username in top-right → "Account Settings"
- Verify the profile section shows name + email
- Change display name → verify it saves
- Change password (enter current + new) → verify it works
- Set up TOTP → scan QR → verify code → see backup codes
- Navigate to passkeys section → verify it shows (if any registered)
- Step 3: Test vendor admin management
- Navigate to /vendor/admins in sidebar
- Verify the current admin appears in the list with "You" badge
- Click "Add Administrator" → create with email + temp password (if email not configured)
- Verify the new admin appears in the list
- Test "Reset Password" action on the new admin
- Test "Reset MFA" action
- Test "Remove" action (should work on other admins, should be disabled on self)
- Step 4: Test tenant settings consolidation
- Sign in as a tenant admin (or switch context)
- Navigate to /tenant/settings
- Verify MFA section, passkey section, and password form still work correctly
- 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.