diff --git a/docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md b/docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md new file mode 100644 index 0000000..34ac822 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md @@ -0,0 +1,2368 @@ +# 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 ( + + + + + + + ); + })} + +
NameEmailActions
{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.