# 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> 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 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>) 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 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 codes = (List) 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 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 updateProfile(@AuthenticationPrincipal Jwt jwt, @RequestBody Map 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 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 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 removeTotp(@AuthenticationPrincipal Jwt jwt) { accountService.removeMfa(jwt.getSubject()); return ResponseEntity.noContent().build(); } // --- Passkeys --- @GetMapping("/mfa/webauthn") public List listPasskeys(@AuthenticationPrincipal Jwt jwt) { return accountService.listPasskeys(jwt.getSubject()); } @PatchMapping("/mfa/webauthn/{id}/name") public ResponseEntity renamePasskey(@AuthenticationPrincipal Jwt jwt, @PathVariable String id, @RequestBody Map 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 deletePasskey(@AuthenticationPrincipal Jwt jwt, @PathVariable String id) { accountService.deletePasskey(jwt.getSubject(), id); return ResponseEntity.noContent().build(); } // --- MFA Preference --- @PostMapping("/mfa/method-preference") public ResponseEntity setMfaPreference(@AuthenticationPrincipal Jwt jwt, @RequestBody Map 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 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 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 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 listAdmins() { return vendorAdminService.listAdmins(); } @PostMapping public CreateAdminResponse createAdmin(@RequestBody CreateAdminRequest request) { return vendorAdminService.createAdmin(request); } @DeleteMapping("/{userId}") public ResponseEntity removeAdmin(@AuthenticationPrincipal Jwt jwt, @PathVariable String userId) { vendorAdminService.removeAdmin(userId, jwt.getSubject()); return ResponseEntity.noContent().build(); } @PostMapping("/{userId}/reset-password") public ResponseEntity resetPassword(@PathVariable String userId, @RequestBody Map body) { vendorAdminService.resetAdminPassword(userId, body.get("password")); return ResponseEntity.noContent().build(); } @DeleteMapping("/{userId}/mfa") public ResponseEntity 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({ queryKey: ['account', 'profile'], queryFn: () => api.get('/account/profile'), }); } export function useUpdateDisplayName() { const qc = useQueryClient(); return useMutation({ mutationFn: (name) => api.patch('/account/profile', { name }), onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'profile'] }), }); } // --- Password --- export function useChangePassword() { return useMutation({ mutationFn: (body) => api.post('/account/password', body), }); } // --- MFA --- export function useAccountMfaStatus() { return useQuery({ queryKey: ['account', 'mfa', 'status'], queryFn: () => api.get('/account/mfa/status'), }); } export function useAccountMfaSetup() { return useMutation({ 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({ mutationFn: () => api.post('/account/mfa/backup-codes'), onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), }); } export function useAccountMfaRemove() { const qc = useQueryClient(); return useMutation({ mutationFn: () => api.delete('/account/mfa/totp'), onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), }); } // --- Passkeys --- export function useAccountPasskeyList() { return useQuery({ queryKey: ['account', 'mfa', 'webauthn'], queryFn: () => api.get('/account/mfa/webauthn'), }); } export function useAccountRenamePasskey() { const qc = useQueryClient(); return useMutation({ 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({ mutationFn: (id) => api.delete(`/account/mfa/webauthn/${id}`), onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), }); } // --- MFA Preference --- export function useAccountMfaPreference() { return useMutation({ 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({ queryKey: ['vendor', 'admins'], queryFn: () => api.get('/vendor/admins'), }); } export function useCreateVendorAdmin() { const qc = useQueryClient(); return useMutation({ mutationFn: (req) => api.post('/vendor/admins', req), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), }); } export function useRemoveVendorAdmin() { const qc = useQueryClient(); return useMutation({ mutationFn: (userId) => api.delete(`/vendor/admins/${userId}`), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), }); } export function useResetVendorAdminPassword() { const qc = useQueryClient(); return useMutation({ 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({ 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 ( Profile { setName(e.target.value); setDirty(true); }} placeholder="Your display name" /> ); } ``` - [ ] **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 ( Change Password
setCurrentPassword(e.target.value)} autoComplete="current-password" /> setNewPassword(e.target.value)} placeholder="Minimum 8 characters" autoComplete="new-password" /> setConfirmPassword(e.target.value)} autoComplete="new-password" /> {newPassword.length > 0 && newPassword.length < 8 && (

Password must be at least 8 characters

)} {confirmPassword.length > 0 && newPassword !== confirmPassword && (

Passwords do not match

)}
); } ``` - [ ] **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(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 ( Backup Codes

Save these codes in a secure place. Each code can only be used once.

{codes.map((code, i) =>
{code}
)}
); } // --- TOTP setup flow --- if (setupData) { return ( Set Up Authenticator

Scan this QR code with your authenticator app, then enter the 6-digit code below.

{setupData.secretQrCode && ( TOTP QR Code )}

Manual entry: {setupData.secret}

setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="000000" maxLength={6} autoFocus />
); } // --- Main view --- return ( Two-Factor Authentication {mfaStatus?.enrolled && Enabled} {mfaStatus?.enrolled ? (
{confirmRemove ? ( <> ) : ( )}
) : ( <>

Add an authenticator app for an extra layer of security.

)}
); } ``` - [ ] **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 (

Passkeys provide passwordless sign-in. Register one during your next sign-in.

); } export function PasskeySection() { const { data: passkeys, isLoading } = useAccountPasskeyList(); const rename = useAccountRenamePasskey(); const del = useAccountDeletePasskey(); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const [deletingId, setDeletingId] = useState(null); if (isLoading) return null; if (!passkeys || passkeys.length === 0) return null; return ( Passkeys
{passkeys.map((pk) => (
{editingId === pk.id ? (
setEditName(e.target.value)} size="sm" autoFocus />
) : ( <>
{pk.name || 'Unnamed passkey'}
{parseAgent(pk.agent)} {pk.createdAt && <> · Added {new Date(pk.createdAt).toLocaleDateString()}}
)}
{editingId !== pk.id && (
{deletingId === pk.id ? ( <> ) : ( )}
)}
))}

New passkeys are registered during sign-in.

); } ``` - [ ] **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 (

Account Settings

); } ``` - [ ] **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 ``, and use the imported ``, ``, ``. 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({ mutationFn: (password) => api.post('/tenant/server/admin-password', { password }), }); } export function useChangeOwnPassword() { return useMutation({ 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(null); const [resetPwValue, setResetPwValue] = useState(''); const [confirmRemoveId, setConfirmRemoveId] = useState(null); const [confirmMfaResetId, setConfirmMfaResetId] = useState(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 (

Platform Administrators

{isLoading ? (

Loading...

) : !admins?.length ? (

No administrators found.

) : ( {admins.map((admin) => { const isSelf = admin.userId === userId; return ( ); })}
Name Email Actions
{admin.name || '—'} {admin.email || '—'} {isSelf && You}
)}
{/* Add Administrator Dialog */} {showAdd && ( { setShowAdd(false); setAddEmail(''); setAddPassword(''); setCreatedResult(null); }}> Add Administrator {createdResult && !createdResult.invited ? (

Administrator created. Share these credentials securely:

Email: {addEmail}
Password: {createdResult.tempPassword}
) : ( <> setAddEmail(e.target.value)} placeholder="admin@example.com" autoFocus /> {!emailConfigured && ( <>

Email connector not configured — set a temporary password.

setAddPassword(e.target.value)} placeholder="Minimum 8 characters" /> )} )}
{!createdResult && ( )}
)} {/* Reset Password Dialog */} {resetPwUserId && ( { setResetPwUserId(null); setResetPwValue(''); }}> Reset Password setResetPwValue(e.target.value)} placeholder="Minimum 8 characters" autoFocus /> )} {/* Confirm Remove Dialog */} {confirmRemoveId && ( setConfirmRemoveId(null)}> Remove Administrator

Remove this user as platform administrator? They will lose access to the vendor console.

)} {/* Confirm MFA Reset Dialog */} {confirmMfaResetId && ( setConfirmMfaResetId(null)}> Reset MFA

Reset all MFA enrollments for this administrator? They will need to re-enroll.

)}
); } ``` 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 `` 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: , onClick: () => navigate('/settings/account'), }, ]; ``` Update the TopBar JSX: ```tsx ``` - [ ] **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
navigate('/vendor/admins')} > Administrators
``` 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 } /> ``` 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 } /> ``` - [ ] **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 && ( )} ``` 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.