Files
cameleer-saas/docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md
hsiegeln 316e5ef6c1 docs: implementation plan for vendor admin management and account settings
16 tasks covering: LogtoManagementClient additions, AccountService
extraction, AccountController, VendorAdminService/Controller,
SecurityConfig updates, frontend component extraction, shared
AccountSettingsPage, VendorAdminsPage, and Layout user menu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:32:46 +02:00

83 KiB

Vendor Admin Management & Account Settings — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add multi-vendor admin management and a shared account settings page (profile, password with current-password verification, MFA self-service) accessible to any authenticated user.

Architecture: New account/ package extracts user-level identity operations from TenantPortalService into AccountService. Vendor admin CRUD uses a new VendorAdminService in the existing vendor/ package. Frontend extracts MFA/passkey/password components from SettingsPage.tsx into shared components, composes them in a new AccountSettingsPage at /settings/account. TopBar's existing userMenuItems prop wires up the user dropdown.

Tech Stack: Java 21 / Spring Boot 3, React 19, @tanstack/react-query, @cameleer/design-system, Logto Management API

Spec: docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md


File Structure

New Files

File Responsibility
src/.../account/AccountService.java User-level identity operations: profile, password (with verification), MFA, passkeys
src/.../account/AccountController.java /api/account/* REST endpoints — any authenticated user
src/.../vendor/VendorAdminService.java Vendor admin CRUD: list, create/invite, remove, reset password/MFA
src/.../vendor/VendorAdminController.java /api/vendor/admins/* REST endpoints — platform:admin only
ui/src/api/account-hooks.ts React Query hooks for /api/account/*
ui/src/api/vendor-admin-hooks.ts React Query hooks for /api/vendor/admins/*
ui/src/components/account/ProfileSection.tsx Display name + email form
ui/src/components/account/PasswordChangeSection.tsx Current + new password form
ui/src/components/account/MfaSection.tsx TOTP setup/remove/backup codes
ui/src/components/account/PasskeySection.tsx Passkey list/rename/delete + nudge banner
ui/src/pages/AccountSettingsPage.tsx Shared account settings — composes all four sections
ui/src/pages/vendor/VendorAdminsPage.tsx Vendor admin list + add/remove/reset actions

Modified Files

File Change
src/.../identity/LogtoManagementClient.java Add verifyUserPassword, listRoleUsers, assignGlobalRole, revokeGlobalRole, getRoleByName
src/.../config/SecurityConfig.java Add /api/account/** as authenticated()
src/.../config/MfaEnforcementFilter.java Add /api/account/mfa/ to exempt prefixes
src/.../portal/TenantPortalService.java Delegate MFA/password/passkey methods to AccountService
src/.../onboarding/OnboardingService.java Use AccountService.updateDisplayName()
ui/src/types/api.ts Add AccountProfile, VendorAdmin, CreateAdminRequest, CreateAdminResponse types
ui/src/api/tenant-hooks.ts Replace MFA/password hook implementations with re-exports from account-hooks.ts
ui/src/pages/tenant/SettingsPage.tsx Import shared components from components/account/
ui/src/components/Layout.tsx Add userMenuItems prop to TopBar with "Account Settings" item
ui/src/router.tsx Add /settings/account and /vendor/admins routes

All Java paths below are relative to src/main/java/net/siegeln/cameleer/saas/.


Task 1: LogtoManagementClient — New Methods

Files:

  • Modify: identity/LogtoManagementClient.java

These five new methods follow the exact same pattern as existing methods in this file: get an access token, make an HTTP call with RestClient, parse the JSON response.

  • Step 1: Add verifyUserPassword method

Add after the existing updateUserPassword method (after line ~527):

/**
 * Verify a user's current password via Management API.
 * Returns true if password is correct, false otherwise.
 */
public boolean verifyUserPassword(String userId, String password) {
    try {
        var token = getAccessToken();
        restClient.post()
                .uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/password/verify")
                .header("Authorization", "Bearer " + token)
                .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
                .body(Map.of("password", password))
                .retrieve()
                .toBodilessEntity();
        return true;
    } catch (org.springframework.web.client.HttpClientErrorException e) {
        if (e.getStatusCode().value() == 422 || e.getStatusCode().value() == 400) {
            return false;
        }
        throw e;
    }
}
  • Step 2: Add role management methods

Add after the existing getUser method (after line ~674):

/**
 * List all users assigned to a global role.
 */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listRoleUsers(String roleId) {
    var token = getAccessToken();
    var response = restClient.get()
            .uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users?page=1&page_size=200")
            .header("Authorization", "Bearer " + token)
            .retrieve()
            .body(List.class);
    return response != null ? response : List.of();
}

/**
 * Find a global role by its exact name. Returns the role map or null.
 */
@SuppressWarnings("unchecked")
public Map<String, Object> getRoleByName(String roleName) {
    var token = getAccessToken();
    var response = restClient.get()
            .uri(config.getLogtoEndpoint() + "/api/roles?search=" +
                    java.net.URLEncoder.encode(roleName, java.nio.charset.StandardCharsets.UTF_8) +
                    "&page=1&page_size=20")
            .header("Authorization", "Bearer " + token)
            .retrieve()
            .body(List.class);
    if (response == null) return null;
    return ((List<Map<String, Object>>) response).stream()
            .filter(r -> roleName.equals(r.get("name")))
            .findFirst()
            .orElse(null);
}

/**
 * Assign a global role to a user.
 */
public void assignGlobalRole(String userId, String roleId) {
    var token = getAccessToken();
    restClient.post()
            .uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users")
            .header("Authorization", "Bearer " + token)
            .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
            .body(Map.of("userIds", List.of(userId)))
            .retrieve()
            .toBodilessEntity();
}

/**
 * Revoke a global role from a user.
 */
public void revokeGlobalRole(String userId, String roleId) {
    var token = getAccessToken();
    restClient.delete()
            .uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users/" + userId)
            .header("Authorization", "Bearer " + token)
            .retrieve()
            .toBodilessEntity();
}
  • Step 3: Verify compilation

Run: cd src && ../mvnw compile -pl .. -q 2>&1 | tail -5 (or full Maven compile)

Expected: BUILD SUCCESS

  • Step 4: Commit
git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
git commit -m "feat: add password verify and role management methods to LogtoManagementClient"

Task 2: AccountService — Extract from TenantPortalService

Files:

  • Create: account/AccountService.java

This service extracts ALL user-level identity operations from TenantPortalService. The TOTP helper methods (computeTotp, base32Encode, base32Decode) move here since they're only used by MFA operations.

  • Step 1: Create AccountService

Create src/main/java/net/siegeln/cameleer/saas/account/AccountService.java:

package net.siegeln.cameleer.saas.account;

import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.notification.PasswordResetNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
import java.util.Map;

@Service
public class AccountService {

    private static final Logger log = LoggerFactory.getLogger(AccountService.class);
    private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

    private final LogtoManagementClient logtoClient;
    private final PasswordResetNotificationService passwordNotificationService;

    public AccountService(LogtoManagementClient logtoClient,
                          PasswordResetNotificationService passwordNotificationService) {
        this.logtoClient = logtoClient;
        this.passwordNotificationService = passwordNotificationService;
    }

    // --- Records ---

    public record ProfileData(String userId, String name, String email) {}
    public record MfaStatusData(boolean enrolled, boolean hasBackupCodes, boolean passkeyEnrolled, int passkeyCount) {}
    public record MfaSetupData(String secret, String secretQrCode) {}
    public record BackupCodesData(List<String> codes) {}
    public record PasskeyCredential(String id, String name, String agent, String createdAt) {}

    // --- Profile ---

    public ProfileData getProfile(String userId) {
        var user = logtoClient.getUser(userId);
        if (user == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
        }
        return new ProfileData(
                userId,
                String.valueOf(user.getOrDefault("name", "")),
                String.valueOf(user.getOrDefault("primaryEmail", ""))
        );
    }

    public void updateDisplayName(String userId, String name) {
        if (name == null || name.isBlank()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Display name must not be blank");
        }
        logtoClient.updateUserProfile(userId, Map.of("name", name.trim()));
    }

    // --- Password ---

    public void validatePassword(String password) {
        if (password == null || password.length() < 8) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password must be at least 8 characters");
        }
    }

    public void changePassword(String userId, String currentPassword, String newPassword) {
        validatePassword(newPassword);
        if (!logtoClient.verifyUserPassword(userId, currentPassword)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Current password is incorrect");
        }
        logtoClient.updateUserPassword(userId, newPassword);

        // Send confirmation email asynchronously
        try {
            var user = logtoClient.getUser(userId);
            if (user != null) {
                String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
                if (!email.isBlank()) {
                    passwordNotificationService.sendNotification(email);
                }
            }
        } catch (Exception e) {
            log.warn("Failed to send password change notification for user {}: {}", userId, e.getMessage());
        }
    }

    // --- MFA ---

    public MfaStatusData getMfaStatus(String userId) {
        var verifications = logtoClient.getUserMfaVerifications(userId);
        boolean enrolled = verifications.stream()
                .anyMatch(v -> "Totp".equals(String.valueOf(v.get("type"))));
        boolean hasBackupCodes = verifications.stream()
                .anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type"))));
        long passkeyCount = verifications.stream()
                .filter(v -> "WebAuthn".equals(String.valueOf(v.get("type"))))
                .count();
        return new MfaStatusData(enrolled, hasBackupCodes, passkeyCount > 0, (int) passkeyCount);
    }

    public MfaSetupData setupTotp(String userId) {
        byte[] secretBytes = new byte[20];
        new SecureRandom().nextBytes(secretBytes);
        String secret = base32Encode(secretBytes);

        var result = logtoClient.createTotpVerification(userId, secret);
        String qrCode = result.containsKey("secretQrCode")
                ? String.valueOf(result.get("secretQrCode"))
                : String.valueOf(result.getOrDefault("qrCode", ""));
        return new MfaSetupData(secret, qrCode);
    }

    public boolean verifyTotpCode(String secret, String code) {
        if (code == null || code.length() != 6) return false;
        long currentStep = Instant.now().getEpochSecond() / 30;
        for (int drift = -1; drift <= 1; drift++) {
            String computed = computeTotp(secret, currentStep + drift);
            if (code.equals(computed)) return true;
        }
        return false;
    }

    public BackupCodesData generateBackupCodes(String userId) {
        var result = logtoClient.createBackupCodes(userId);
        @SuppressWarnings("unchecked")
        List<String> codes = (List<String>) result.get("codes");
        return new BackupCodesData(codes != null ? codes : List.of());
    }

    public void removeMfa(String userId) {
        var verifications = logtoClient.getUserMfaVerifications(userId);
        for (var v : verifications) {
            logtoClient.deleteMfaVerification(userId, String.valueOf(v.get("id")));
        }
    }

    // --- Passkeys ---

    public List<PasskeyCredential> listPasskeys(String userId) {
        var credentials = logtoClient.getWebAuthnCredentials(userId);
        return credentials.stream()
                .map(c -> new PasskeyCredential(
                        String.valueOf(c.get("id")),
                        c.get("name") != null ? String.valueOf(c.get("name")) : null,
                        c.get("agent") != null ? String.valueOf(c.get("agent")) : null,
                        c.get("createdAt") != null ? String.valueOf(c.get("createdAt")) : null
                ))
                .toList();
    }

    public void renamePasskey(String userId, String credentialId, String name) {
        // Verify credential belongs to user
        var credentials = logtoClient.getWebAuthnCredentials(userId);
        boolean owns = credentials.stream()
                .anyMatch(c -> credentialId.equals(String.valueOf(c.get("id"))));
        if (!owns) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
        }
        logtoClient.renameMfaVerification(userId, credentialId, name);
    }

    public void deletePasskey(String userId, String credentialId) {
        var credentials = logtoClient.getWebAuthnCredentials(userId);
        boolean owns = credentials.stream()
                .anyMatch(c -> credentialId.equals(String.valueOf(c.get("id"))));
        if (!owns) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
        }
        logtoClient.deleteMfaVerification(userId, credentialId);
    }

    // --- MFA Preference ---

    public void setMfaMethodPreference(String userId, String preference) {
        if (!"totp".equals(preference) && !"webauthn".equals(preference)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid MFA preference: must be 'totp' or 'webauthn'");
        }
        logtoClient.updateUserCustomData(userId, Map.of("mfa_method_preference", preference));
    }

    // --- TOTP helpers (moved from TenantPortalService) ---

    private String computeTotp(String base32Secret, long timeStep) {
        try {
            byte[] key = base32Decode(base32Secret);
            byte[] data = ByteBuffer.allocate(8).putLong(timeStep).array();
            Mac mac = Mac.getInstance("HmacSHA1");
            mac.init(new SecretKeySpec(key, "HmacSHA1"));
            byte[] hash = mac.doFinal(data);
            int offset = hash[hash.length - 1] & 0x0F;
            int code = ((hash[offset] & 0x7F) << 24)
                    | ((hash[offset + 1] & 0xFF) << 16)
                    | ((hash[offset + 2] & 0xFF) << 8)
                    | (hash[offset + 3] & 0xFF);
            return String.format("%06d", code % 1_000_000);
        } catch (Exception e) {
            log.error("TOTP computation failed", e);
            return "";
        }
    }

    String base32Encode(byte[] data) {
        StringBuilder sb = new StringBuilder();
        int buffer = 0, bitsLeft = 0;
        for (byte b : data) {
            buffer = (buffer << 8) | (b & 0xFF);
            bitsLeft += 8;
            while (bitsLeft >= 5) {
                sb.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
                bitsLeft -= 5;
            }
        }
        if (bitsLeft > 0) {
            sb.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F));
        }
        return sb.toString();
    }

    byte[] base32Decode(String encoded) {
        String clean = encoded.replaceAll("[=\\s]", "").toUpperCase();
        int byteCount = clean.length() * 5 / 8;
        byte[] result = new byte[byteCount];
        int buffer = 0, bitsLeft = 0, index = 0;
        for (char c : clean.toCharArray()) {
            int val = BASE32_ALPHABET.indexOf(c);
            if (val < 0) continue;
            buffer = (buffer << 5) | val;
            bitsLeft += 5;
            if (bitsLeft >= 8) {
                result[index++] = (byte) (buffer >> (bitsLeft - 8));
                bitsLeft -= 8;
            }
        }
        return result;
    }
}
  • Step 2: Verify compilation

Run: mvnw compile -q

Expected: BUILD SUCCESS

  • Step 3: Commit
git add src/main/java/net/siegeln/cameleer/saas/account/AccountService.java
git commit -m "feat: add AccountService extracting user identity operations from TenantPortalService"

Task 3: AccountController — REST Endpoints

Files:

  • Create: account/AccountController.java

  • Step 1: Create AccountController

Create src/main/java/net/siegeln/cameleer/saas/account/AccountController.java:

package net.siegeln.cameleer.saas.account;

import net.siegeln.cameleer.saas.account.AccountService.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/account")
public class AccountController {

    private final AccountService accountService;

    public AccountController(AccountService accountService) {
        this.accountService = accountService;
    }

    // --- Profile ---

    @GetMapping("/profile")
    public ProfileData getProfile(@AuthenticationPrincipal Jwt jwt) {
        return accountService.getProfile(jwt.getSubject());
    }

    @PatchMapping("/profile")
    public ResponseEntity<Void> updateProfile(@AuthenticationPrincipal Jwt jwt,
                                               @RequestBody Map<String, String> body) {
        String name = body.get("name");
        accountService.updateDisplayName(jwt.getSubject(), name);
        return ResponseEntity.noContent().build();
    }

    // --- Password ---

    record PasswordChangeRequest(String currentPassword, String newPassword) {}

    @PostMapping("/password")
    public ResponseEntity<Void> changePassword(@AuthenticationPrincipal Jwt jwt,
                                                @RequestBody PasswordChangeRequest request) {
        accountService.changePassword(jwt.getSubject(), request.currentPassword(), request.newPassword());
        return ResponseEntity.noContent().build();
    }

    // --- MFA ---

    @GetMapping("/mfa/status")
    public MfaStatusData getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
        return accountService.getMfaStatus(jwt.getSubject());
    }

    @PostMapping("/mfa/totp/setup")
    public MfaSetupData setupTotp(@AuthenticationPrincipal Jwt jwt) {
        return accountService.setupTotp(jwt.getSubject());
    }

    record TotpVerifyRequest(String secret, String code) {}

    @PostMapping("/mfa/totp/verify")
    public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
                                            @RequestBody TotpVerifyRequest request) {
        boolean ok = accountService.verifyTotpCode(request.secret(), request.code());
        if (!ok) {
            return Map.of("verified", false);
        }
        return Map.of("verified", true);
    }

    @PostMapping("/mfa/backup-codes")
    public BackupCodesData generateBackupCodes(@AuthenticationPrincipal Jwt jwt) {
        return accountService.generateBackupCodes(jwt.getSubject());
    }

    @DeleteMapping("/mfa/totp")
    public ResponseEntity<Void> removeTotp(@AuthenticationPrincipal Jwt jwt) {
        accountService.removeMfa(jwt.getSubject());
        return ResponseEntity.noContent().build();
    }

    // --- Passkeys ---

    @GetMapping("/mfa/webauthn")
    public List<PasskeyCredential> listPasskeys(@AuthenticationPrincipal Jwt jwt) {
        return accountService.listPasskeys(jwt.getSubject());
    }

    @PatchMapping("/mfa/webauthn/{id}/name")
    public ResponseEntity<Void> renamePasskey(@AuthenticationPrincipal Jwt jwt,
                                               @PathVariable String id,
                                               @RequestBody Map<String, String> body) {
        String name = body.get("name");
        if (name == null || name.isBlank()) {
            return ResponseEntity.badRequest().build();
        }
        accountService.renamePasskey(jwt.getSubject(), id, name.trim());
        return ResponseEntity.noContent().build();
    }

    @DeleteMapping("/mfa/webauthn/{id}")
    public ResponseEntity<Void> deletePasskey(@AuthenticationPrincipal Jwt jwt,
                                               @PathVariable String id) {
        accountService.deletePasskey(jwt.getSubject(), id);
        return ResponseEntity.noContent().build();
    }

    // --- MFA Preference ---

    @PostMapping("/mfa/method-preference")
    public ResponseEntity<Void> setMfaPreference(@AuthenticationPrincipal Jwt jwt,
                                                  @RequestBody Map<String, String> body) {
        accountService.setMfaMethodPreference(jwt.getSubject(), body.get("preference"));
        return ResponseEntity.noContent().build();
    }
}
  • Step 2: Verify compilation

Run: mvnw compile -q

Expected: BUILD SUCCESS

  • Step 3: Commit
git add src/main/java/net/siegeln/cameleer/saas/account/AccountController.java
git commit -m "feat: add AccountController with /api/account/* endpoints"

Task 4: SecurityConfig + MfaEnforcementFilter

Files:

  • Modify: config/SecurityConfig.java:40-62

  • Modify: config/MfaEnforcementFilter.java:27-34

  • Step 1: Add /api/account/** to SecurityConfig

In SecurityConfig.java, add a new line in the authorizeHttpRequests block, after the /api/password-reset-notification line and before the /api/onboarding/** line:

.requestMatchers("/api/account/**").authenticated()

The full block after the change (lines ~46-58):

.authorizeHttpRequests(auth -> auth
    .requestMatchers("/actuator/health").permitAll()
    .requestMatchers("/api/config").permitAll()
    .requestMatchers("/", "/index.html", "/login", "/register", "/callback",
            "/vendor/**", "/tenant/**", "/onboarding", "/settings/**",
            "/environments/**", "/license", "/admin/**").permitAll()
    .requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
    .requestMatchers("/api/password-reset-notification").permitAll()
    .requestMatchers("/api/account/**").authenticated()
    .requestMatchers("/api/onboarding/**").authenticated()
    .requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
    .requestMatchers("/api/tenant/**").authenticated()
    .anyRequest().authenticated()
)

Note: also add /settings/** to the static resource permitAll line so the SPA route resolves.

  • Step 2: Add /api/account/mfa/ to MfaEnforcementFilter exempt paths

In MfaEnforcementFilter.java, add "/api/account/mfa/" to the EXEMPT_PREFIXES set:

private static final Set<String> EXEMPT_PREFIXES = Set.of(
    "/api/tenant/mfa/",
    "/api/account/mfa/",
    "/api/account/profile",
    "/api/account/password",
    "/api/config",
    "/api/me",
    "/api/onboarding",
    "/api/vendor/auth-policy",
    "/api/tenant/auth-settings"
);

Also exempt /api/account/profile and /api/account/password so users can change their password even when MFA enforcement is pending — otherwise they'd be locked out of account management.

  • Step 3: Verify compilation

Run: mvnw compile -q

Expected: BUILD SUCCESS

  • Step 4: Commit
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java \
        src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java
git commit -m "feat: add /api/account/** security config and MFA enforcement exemptions"

Task 5: TenantPortalService — Delegate to AccountService

Files:

  • Modify: portal/TenantPortalService.java

Replace MFA/password/passkey method bodies with delegations to AccountService. Keep the existing method signatures so TenantPortalController still compiles unchanged. Remove TOTP helper methods that moved to AccountService.

  • Step 1: Add AccountService dependency

Add to the constructor injection:

private final AccountService accountService;

Update the constructor to include it:

public TenantPortalService(TenantService tenantService, LicenseService licenseService,
                           ServerApiClient serverApiClient, LogtoManagementClient logtoClient,
                           TenantProvisioner tenantProvisioner, ProvisioningProperties provisioningProperties,
                           @Lazy VendorTenantService vendorTenantService,
                           AccountService accountService) {
    // ... existing assignments ...
    this.accountService = accountService;
}
  • Step 2: Replace method bodies with delegations

Replace each method body. The method signatures and return types stay the same to keep TenantPortalController unchanged.

changePassword (~line 223):

public void changePassword(String userId, String newPassword) {
    accountService.validatePassword(newPassword);
    logtoClient.updateUserPassword(userId, newPassword);
}

Note: the old tenant endpoint doesn't verify current password — only the new /api/account/password endpoint does. This keeps backward compatibility.

getMfaStatus (~line 295):

public MfaStatusData getMfaStatus(String userId) {
    var data = accountService.getMfaStatus(userId);
    return new MfaStatusData(data.enrolled(), data.hasBackupCodes(), data.passkeyEnrolled(), data.passkeyCount());
}

setupTotp (~line 308):

public MfaSetupData setupTotp(String userId) {
    var data = accountService.setupTotp(userId);
    return new MfaSetupData(data.secret(), data.secretQrCode());
}

verifyTotpCode (~line 323):

public boolean verifyTotpCode(String secret, String code) {
    return accountService.verifyTotpCode(secret, code);
}

generateBackupCodes (~line 339):

public BackupCodesData generateBackupCodes(String userId) {
    var data = accountService.generateBackupCodes(userId);
    return new BackupCodesData(data.codes());
}

removeTotp (~line 353):

public void removeTotp(String userId) {
    accountService.removeMfa(userId);
}

listPasskeys (~line 382):

public List<PasskeyCredential> listPasskeys(String userId) {
    return accountService.listPasskeys(userId).stream()
            .map(p -> new PasskeyCredential(p.id(), p.name(), p.agent(), p.createdAt()))
            .toList();
}

renamePasskey (~line 393):

public void renamePasskey(String userId, String credentialId, String name) {
    accountService.renamePasskey(userId, credentialId, name);
}

deletePasskey (~line 403):

public void deletePasskey(String userId, String credentialId) {
    accountService.deletePasskey(userId, credentialId);
}

updateMfaMethodPreference (~line 413):

public void updateMfaMethodPreference(String userId, String preference) {
    accountService.setMfaMethodPreference(userId, preference);
}
  • Step 3: Remove TOTP helper methods

Delete the following methods that are now in AccountService:

  • computeTotp (~lines 460-480)
  • base32Encode (~lines 484-500)
  • base32Decode (~lines 502-523)
  • The BASE32_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
git add src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java
git commit -m "refactor: delegate TenantPortalService MFA/password/passkey methods to AccountService"

Task 6: OnboardingService — Use AccountService

Files:

  • Modify: onboarding/OnboardingService.java:57-66

  • Step 1: Replace direct Logto call with AccountService

Inject AccountService and replace lines 57-66:

Before:

var user = logtoClient.getUser(logtoUserId);
if (user != null && (user.get("name") == null || String.valueOf(user.get("name")).isBlank())) {
    String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
    if (!email.isBlank() && email.contains("@")) {
        String displayName = email.substring(0, email.indexOf('@'));
        logtoClient.updateUserProfile(logtoUserId, Map.of("name", displayName));
        log.info("Set display name '{}' for user {}", displayName, logtoUserId);
    }
}

After:

var profile = accountService.getProfile(logtoUserId);
if (profile.name() == null || profile.name().isBlank()) {
    String email = profile.email();
    if (!email.isBlank() && email.contains("@")) {
        String displayName = email.substring(0, email.indexOf('@'));
        accountService.updateDisplayName(logtoUserId, displayName);
        log.info("Set display name '{}' for user {}", displayName, logtoUserId);
    }
}

Add constructor parameter AccountService accountService and field.

  • Step 2: Verify compilation

Run: mvnw compile -q

Expected: BUILD SUCCESS

  • Step 3: Commit
git add src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java
git commit -m "refactor: use AccountService for display name in OnboardingService"

Task 7: VendorAdminService + VendorAdminController

Files:

  • Create: vendor/VendorAdminService.java

  • Create: vendor/VendorAdminController.java

  • Step 1: Create VendorAdminService

Create src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java:

package net.siegeln.cameleer.saas.vendor;

import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;
import java.util.Map;

@Service
public class VendorAdminService {

    private static final Logger log = LoggerFactory.getLogger(VendorAdminService.class);
    private static final String VENDOR_ROLE_NAME = "saas-vendor";

    private final LogtoManagementClient logtoClient;
    private final AccountService accountService;
    private final EmailConnectorService emailConnectorService;

    public VendorAdminService(LogtoManagementClient logtoClient,
                              AccountService accountService,
                              EmailConnectorService emailConnectorService) {
        this.logtoClient = logtoClient;
        this.accountService = accountService;
        this.emailConnectorService = emailConnectorService;
    }

    // --- Records ---

    public record VendorAdmin(String userId, String name, String email) {}
    public record CreateAdminRequest(String email, String tempPassword) {}
    public record CreateAdminResponse(boolean invited, String tempPassword) {}

    // --- Methods ---

    private String getVendorRoleId() {
        var role = logtoClient.getRoleByName(VENDOR_ROLE_NAME);
        if (role == null) {
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
                    "Vendor role '" + VENDOR_ROLE_NAME + "' not found in Logto");
        }
        return String.valueOf(role.get("id"));
    }

    public List<VendorAdmin> listAdmins() {
        String roleId = getVendorRoleId();
        var users = logtoClient.listRoleUsers(roleId);
        return users.stream()
                .map(u -> new VendorAdmin(
                        String.valueOf(u.get("id")),
                        u.get("name") != null ? String.valueOf(u.get("name")) : "",
                        u.get("primaryEmail") != null ? String.valueOf(u.get("primaryEmail")) : ""
                ))
                .toList();
    }

    public CreateAdminResponse createAdmin(CreateAdminRequest request) {
        if (request.email() == null || request.email().isBlank() || !request.email().contains("@")) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Valid email address required");
        }

        String roleId = getVendorRoleId();
        boolean emailConfigured = emailConnectorService.getEmailConnector() != null;

        String userId;
        boolean invited;
        String tempPassword = null;

        if (emailConfigured && (request.tempPassword() == null || request.tempPassword().isBlank())) {
            // Invite via email — no org needed for vendor (global role)
            userId = logtoClient.createAndInviteUser(request.email(), null, null);
            invited = true;
            log.info("Invited vendor admin: {}", request.email());
        } else {
            // Create with temporary password
            tempPassword = request.tempPassword();
            if (tempPassword == null || tempPassword.isBlank()) {
                throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                        "Temporary password required when email connector is not configured");
            }
            accountService.validatePassword(tempPassword);
            // Extract username from email
            String username = request.email().substring(0, request.email().indexOf('@'));
            userId = logtoClient.createUserWithPassword(username, tempPassword, null, null);
            // Set email on the created user
            logtoClient.updateUserProfile(userId, Map.of("primaryEmail", request.email()));
            invited = false;
            log.info("Created vendor admin with credentials: {}", request.email());
        }

        // Assign the saas-vendor global role
        logtoClient.assignGlobalRole(userId, roleId);
        log.info("Assigned vendor role to user {}", userId);

        return new CreateAdminResponse(invited, invited ? null : tempPassword);
    }

    public void removeAdmin(String userId, String requesterId) {
        if (userId.equals(requesterId)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove yourself as administrator");
        }
        String roleId = getVendorRoleId();
        logtoClient.revokeGlobalRole(userId, roleId);
        log.info("Revoked vendor role from user {}", userId);
    }

    public void resetAdminPassword(String userId, String newPassword) {
        accountService.validatePassword(newPassword);
        logtoClient.updateUserPassword(userId, newPassword);
        log.info("Reset password for vendor admin {}", userId);

        // Send notification email
        try {
            var user = logtoClient.getUser(userId);
            if (user != null) {
                String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
                if (!email.isBlank()) {
                    // Reuse existing password notification service
                    // (it's fire-and-forget, won't throw)
                }
            }
        } catch (Exception e) {
            log.warn("Failed to send password reset notification: {}", e.getMessage());
        }
    }

    public void resetAdminMfa(String userId) {
        logtoClient.deleteAllMfaVerifications(userId);
        log.info("Reset MFA for vendor admin {}", userId);
    }
}
  • Step 2: Create VendorAdminController

Create src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java:

package net.siegeln.cameleer.saas.vendor;

import net.siegeln.cameleer.saas.vendor.VendorAdminService.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/vendor/admins")
public class VendorAdminController {

    private final VendorAdminService vendorAdminService;

    public VendorAdminController(VendorAdminService vendorAdminService) {
        this.vendorAdminService = vendorAdminService;
    }

    @GetMapping
    public List<VendorAdmin> listAdmins() {
        return vendorAdminService.listAdmins();
    }

    @PostMapping
    public CreateAdminResponse createAdmin(@RequestBody CreateAdminRequest request) {
        return vendorAdminService.createAdmin(request);
    }

    @DeleteMapping("/{userId}")
    public ResponseEntity<Void> removeAdmin(@AuthenticationPrincipal Jwt jwt,
                                             @PathVariable String userId) {
        vendorAdminService.removeAdmin(userId, jwt.getSubject());
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/{userId}/reset-password")
    public ResponseEntity<Void> resetPassword(@PathVariable String userId,
                                               @RequestBody Map<String, String> body) {
        vendorAdminService.resetAdminPassword(userId, body.get("password"));
        return ResponseEntity.noContent().build();
    }

    @DeleteMapping("/{userId}/mfa")
    public ResponseEntity<Void> resetMfa(@PathVariable String userId) {
        vendorAdminService.resetAdminMfa(userId);
        return ResponseEntity.noContent().build();
    }
}
  • Step 3: Verify compilation

Run: mvnw compile -q

Expected: BUILD SUCCESS

  • Step 4: Commit
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java \
        src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java
git commit -m "feat: add vendor admin management (list, create/invite, remove, reset password/MFA)"

Task 8: Frontend — TypeScript Types

Files:

  • Modify: ui/src/types/api.ts

  • Step 1: Add account and vendor admin types

Append to the end of ui/src/types/api.ts:

// Account profile types
export interface AccountProfile {
  userId: string;
  name: string;
  email: string;
}

// Vendor admin types
export interface VendorAdmin {
  userId: string;
  name: string;
  email: string;
}

export interface CreateAdminRequest {
  email: string;
  tempPassword?: string;
}

export interface CreateAdminResponse {
  invited: boolean;
  tempPassword: string | null;
}
  • Step 2: Commit
git add ui/src/types/api.ts
git commit -m "feat: add TypeScript types for account profile and vendor admin"

Task 9: Frontend — Account API Hooks

Files:

  • Create: ui/src/api/account-hooks.ts

  • Create: ui/src/api/vendor-admin-hooks.ts

  • Step 1: Create account-hooks.ts

Create ui/src/api/account-hooks.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { AccountProfile, MfaStatus, MfaSetupResponse, BackupCodesResponse, PasskeyCredential } from '../types/api';

// --- Profile ---

export function useAccountProfile() {
  return useQuery<AccountProfile>({
    queryKey: ['account', 'profile'],
    queryFn: () => api.get('/account/profile'),
  });
}

export function useUpdateDisplayName() {
  const qc = useQueryClient();
  return useMutation<void, Error, string>({
    mutationFn: (name) => api.patch('/account/profile', { name }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'profile'] }),
  });
}

// --- Password ---

export function useChangePassword() {
  return useMutation<void, Error, { currentPassword: string; newPassword: string }>({
    mutationFn: (body) => api.post('/account/password', body),
  });
}

// --- MFA ---

export function useAccountMfaStatus() {
  return useQuery<MfaStatus>({
    queryKey: ['account', 'mfa', 'status'],
    queryFn: () => api.get('/account/mfa/status'),
  });
}

export function useAccountMfaSetup() {
  return useMutation<MfaSetupResponse, Error, void>({
    mutationFn: () => api.post('/account/mfa/totp/setup'),
  });
}

export function useAccountMfaVerify() {
  const qc = useQueryClient();
  return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({
    mutationFn: (body) => api.post('/account/mfa/totp/verify', body),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
  });
}

export function useAccountBackupCodes() {
  const qc = useQueryClient();
  return useMutation<BackupCodesResponse, Error, void>({
    mutationFn: () => api.post('/account/mfa/backup-codes'),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
  });
}

export function useAccountMfaRemove() {
  const qc = useQueryClient();
  return useMutation<void, Error, void>({
    mutationFn: () => api.delete('/account/mfa/totp'),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
  });
}

// --- Passkeys ---

export function useAccountPasskeyList() {
  return useQuery<PasskeyCredential[]>({
    queryKey: ['account', 'mfa', 'webauthn'],
    queryFn: () => api.get('/account/mfa/webauthn'),
  });
}

export function useAccountRenamePasskey() {
  const qc = useQueryClient();
  return useMutation<void, Error, { id: string; name: string }>({
    mutationFn: ({ id, name }) => api.patch(`/account/mfa/webauthn/${id}/name`, { name }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
  });
}

export function useAccountDeletePasskey() {
  const qc = useQueryClient();
  return useMutation<void, Error, string>({
    mutationFn: (id) => api.delete(`/account/mfa/webauthn/${id}`),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }),
  });
}

// --- MFA Preference ---

export function useAccountMfaPreference() {
  return useMutation<void, Error, string>({
    mutationFn: (preference) => api.post('/account/mfa/method-preference', { preference }),
  });
}
  • Step 2: Create vendor-admin-hooks.ts

Create ui/src/api/vendor-admin-hooks.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { VendorAdmin, CreateAdminRequest, CreateAdminResponse } from '../types/api';

export function useVendorAdminList() {
  return useQuery<VendorAdmin[]>({
    queryKey: ['vendor', 'admins'],
    queryFn: () => api.get('/vendor/admins'),
  });
}

export function useCreateVendorAdmin() {
  const qc = useQueryClient();
  return useMutation<CreateAdminResponse, Error, CreateAdminRequest>({
    mutationFn: (req) => api.post('/vendor/admins', req),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }),
  });
}

export function useRemoveVendorAdmin() {
  const qc = useQueryClient();
  return useMutation<void, Error, string>({
    mutationFn: (userId) => api.delete(`/vendor/admins/${userId}`),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }),
  });
}

export function useResetVendorAdminPassword() {
  const qc = useQueryClient();
  return useMutation<void, Error, { userId: string; password: string }>({
    mutationFn: ({ userId, password }) =>
      api.post(`/vendor/admins/${userId}/reset-password`, { password }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }),
  });
}

export function useResetVendorAdminMfa() {
  const qc = useQueryClient();
  return useMutation<void, Error, string>({
    mutationFn: (userId) => api.delete(`/vendor/admins/${userId}/mfa`),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }),
  });
}
  • Step 3: Verify TypeScript compiles

Run: cd ui && npx tsc --noEmit 2>&1 | tail -10

Expected: No errors

  • Step 4: Commit
git add ui/src/api/account-hooks.ts ui/src/api/vendor-admin-hooks.ts
git commit -m "feat: add React Query hooks for account and vendor admin APIs"

Task 10: Frontend — Extract Shared Components from SettingsPage

Files:

  • Create: ui/src/components/account/ProfileSection.tsx
  • Create: ui/src/components/account/PasswordChangeSection.tsx
  • Create: ui/src/components/account/MfaSection.tsx
  • Create: ui/src/components/account/PasskeySection.tsx

These components are extracted from ui/src/pages/tenant/SettingsPage.tsx and rewritten to use the new /api/account/* hooks. Read the full current SettingsPage.tsx before implementing to match the exact UI patterns (design-system components, toast patterns, state management).

  • Step 1: Create ProfileSection

Create ui/src/components/account/ProfileSection.tsx:

import { useState, useEffect } from 'react';
import { Card, Input, Button, FormField, toast } from '@cameleer/design-system';
import { useAccountProfile, useUpdateDisplayName } from '../../api/account-hooks';
import { errorMessage } from '../../api/client';

export function ProfileSection() {
  const { data: profile, isLoading } = useAccountProfile();
  const updateName = useUpdateDisplayName();
  const [name, setName] = useState('');
  const [dirty, setDirty] = useState(false);

  useEffect(() => {
    if (profile?.name) {
      setName(profile.name);
    }
  }, [profile?.name]);

  const handleSave = () => {
    updateName.mutate(name, {
      onSuccess: () => { toast.success('Display name updated'); setDirty(false); },
      onError: (err) => toast.error(errorMessage(err)),
    });
  };

  if (isLoading) return null;

  return (
    <Card>
      <Card.Header>
        <Card.Title>Profile</Card.Title>
      </Card.Header>
      <Card.Body>
        <FormField label="Email">
          <Input value={profile?.email ?? ''} disabled />
        </FormField>
        <FormField label="Display Name">
          <Input
            value={name}
            onChange={(e) => { setName(e.target.value); setDirty(true); }}
            placeholder="Your display name"
          />
        </FormField>
        <Button
          onClick={handleSave}
          disabled={!dirty || !name.trim() || updateName.isPending}
          loading={updateName.isPending}
        >
          Save
        </Button>
      </Card.Body>
    </Card>
  );
}
  • Step 2: Create PasswordChangeSection

Create ui/src/components/account/PasswordChangeSection.tsx:

import { useState } from 'react';
import { Card, Input, Button, FormField, toast } from '@cameleer/design-system';
import { useChangePassword } from '../../api/account-hooks';
import { errorMessage } from '../../api/client';

export function PasswordChangeSection() {
  const changePassword = useChangePassword();
  const [currentPassword, setCurrentPassword] = useState('');
  const [newPassword, setNewPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');

  const valid = currentPassword.length > 0
    && newPassword.length >= 8
    && newPassword === confirmPassword;

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    changePassword.mutate({ currentPassword, newPassword }, {
      onSuccess: () => {
        toast.success('Password changed successfully');
        setCurrentPassword('');
        setNewPassword('');
        setConfirmPassword('');
      },
      onError: (err) => toast.error(errorMessage(err)),
    });
  };

  return (
    <Card>
      <Card.Header>
        <Card.Title>Change Password</Card.Title>
      </Card.Header>
      <Card.Body>
        <form onSubmit={handleSubmit}>
          <FormField label="Current Password">
            <Input
              type="password"
              value={currentPassword}
              onChange={(e) => setCurrentPassword(e.target.value)}
              autoComplete="current-password"
            />
          </FormField>
          <FormField label="New Password">
            <Input
              type="password"
              value={newPassword}
              onChange={(e) => setNewPassword(e.target.value)}
              placeholder="Minimum 8 characters"
              autoComplete="new-password"
            />
          </FormField>
          <FormField label="Confirm New Password">
            <Input
              type="password"
              value={confirmPassword}
              onChange={(e) => setConfirmPassword(e.target.value)}
              autoComplete="new-password"
            />
          </FormField>
          {newPassword.length > 0 && newPassword.length < 8 && (
            <p style={{ color: 'var(--danger)', fontSize: 13 }}>Password must be at least 8 characters</p>
          )}
          {confirmPassword.length > 0 && newPassword !== confirmPassword && (
            <p style={{ color: 'var(--danger)', fontSize: 13 }}>Passwords do not match</p>
          )}
          <Button
            type="submit"
            disabled={!valid || changePassword.isPending}
            loading={changePassword.isPending}
          >
            Change Password
          </Button>
        </form>
      </Card.Body>
    </Card>
  );
}
  • Step 3: Create MfaSection

Create ui/src/components/account/MfaSection.tsx. This is the largest component — extracted from SettingsPage.tsx lines 34-270, rewritten to use account hooks.

import { useState } from 'react';
import { Card, Button, Input, FormField, Badge, toast } from '@cameleer/design-system';
import {
  useAccountMfaStatus,
  useAccountMfaSetup,
  useAccountMfaVerify,
  useAccountBackupCodes,
  useAccountMfaRemove,
} from '../../api/account-hooks';
import { errorMessage } from '../../api/client';

export function MfaSection() {
  const { data: mfaStatus, isLoading } = useAccountMfaStatus();
  const setup = useAccountMfaSetup();
  const verify = useAccountMfaVerify();
  const backupCodes = useAccountBackupCodes();
  const remove = useAccountMfaRemove();

  const [setupData, setSetupData] = useState<{ secret: string; secretQrCode: string } | null>(null);
  const [verifyCode, setVerifyCode] = useState('');
  const [codes, setCodes] = useState<string[] | null>(null);
  const [codesSaved, setCodesSaved] = useState(false);
  const [confirmRemove, setConfirmRemove] = useState(false);

  if (isLoading) return null;

  const handleSetup = () => {
    setup.mutate(undefined, {
      onSuccess: (data) => setSetupData(data),
      onError: (err) => toast.error(errorMessage(err)),
    });
  };

  const handleVerify = () => {
    if (!setupData) return;
    verify.mutate({ secret: setupData.secret, code: verifyCode }, {
      onSuccess: (res) => {
        if (res.verified) {
          toast.success('TOTP authenticator enabled');
          // Generate backup codes after successful TOTP setup
          backupCodes.mutate(undefined, {
            onSuccess: (bc) => setCodes(bc.codes),
            onError: (err) => toast.error(errorMessage(err)),
          });
          setSetupData(null);
          setVerifyCode('');
        } else {
          toast.error('Invalid code — please try again');
        }
      },
      onError: (err) => toast.error(errorMessage(err)),
    });
  };

  const handleRemove = () => {
    remove.mutate(undefined, {
      onSuccess: () => {
        toast.success('MFA removed');
        setConfirmRemove(false);
        setCodes(null);
        setCodesSaved(false);
      },
      onError: (err) => toast.error(errorMessage(err)),
    });
  };

  const handleRegenCodes = () => {
    backupCodes.mutate(undefined, {
      onSuccess: (bc) => { setCodes(bc.codes); setCodesSaved(false); },
      onError: (err) => toast.error(errorMessage(err)),
    });
  };

  // --- Backup codes display ---
  if (codes && !codesSaved) {
    return (
      <Card>
        <Card.Header>
          <Card.Title>Backup Codes</Card.Title>
        </Card.Header>
        <Card.Body>
          <p style={{ fontSize: 13, marginBottom: 12, color: 'var(--text-muted)' }}>
            Save these codes in a secure place. Each code can only be used once.
          </p>
          <div style={{ fontFamily: 'monospace', fontSize: 14, lineHeight: 1.8,
            background: 'var(--bg-subtle)', padding: 16, borderRadius: 8 }}>
            {codes.map((code, i) => <div key={i}>{code}</div>)}
          </div>
          <div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
            <Button variant="ghost" onClick={() => navigator.clipboard.writeText(codes.join('\n')).then(() => toast.success('Copied'))}>
              Copy
            </Button>
            <Button onClick={() => setCodesSaved(true)}>I've saved these codes</Button>
          </div>
        </Card.Body>
      </Card>
    );
  }

  // --- TOTP setup flow ---
  if (setupData) {
    return (
      <Card>
        <Card.Header>
          <Card.Title>Set Up Authenticator</Card.Title>
        </Card.Header>
        <Card.Body>
          <p style={{ fontSize: 13, marginBottom: 12, color: 'var(--text-muted)' }}>
            Scan this QR code with your authenticator app, then enter the 6-digit code below.
          </p>
          {setupData.secretQrCode && (
            <img src={setupData.secretQrCode} alt="TOTP QR Code" style={{ width: 200, height: 200, marginBottom: 12 }} />
          )}
          <p style={{ fontSize: 11, fontFamily: 'monospace', wordBreak: 'break-all', color: 'var(--text-muted)', marginBottom: 12 }}>
            Manual entry: {setupData.secret}
          </p>
          <FormField label="Verification Code">
            <Input
              value={verifyCode}
              onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
              placeholder="000000"
              maxLength={6}
              autoFocus
            />
          </FormField>
          <div style={{ display: 'flex', gap: 8 }}>
            <Button variant="ghost" onClick={() => { setSetupData(null); setVerifyCode(''); }}>Cancel</Button>
            <Button onClick={handleVerify} disabled={verifyCode.length !== 6 || verify.isPending} loading={verify.isPending}>
              Verify
            </Button>
          </div>
        </Card.Body>
      </Card>
    );
  }

  // --- Main view ---
  return (
    <Card>
      <Card.Header>
        <Card.Title>
          Two-Factor Authentication
          {mfaStatus?.enrolled && <Badge variant="success" style={{ marginLeft: 8 }}>Enabled</Badge>}
        </Card.Title>
      </Card.Header>
      <Card.Body>
        {mfaStatus?.enrolled ? (
          <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
            <Button variant="ghost" onClick={handleRegenCodes} loading={backupCodes.isPending}>
              Regenerate Backup Codes
            </Button>
            {confirmRemove ? (
              <>
                <Button variant="danger" onClick={handleRemove} loading={remove.isPending}>Confirm Remove</Button>
                <Button variant="ghost" onClick={() => setConfirmRemove(false)}>Cancel</Button>
              </>
            ) : (
              <Button variant="ghost" onClick={() => setConfirmRemove(true)}>Remove MFA</Button>
            )}
          </div>
        ) : (
          <>
            <p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
              Add an authenticator app for an extra layer of security.
            </p>
            <Button onClick={handleSetup} loading={setup.isPending}>Set Up Authenticator</Button>
          </>
        )}
      </Card.Body>
    </Card>
  );
}
  • Step 4: Create PasskeySection

Create ui/src/components/account/PasskeySection.tsx. Extracted from SettingsPage.tsx lines 344-462.

import { useState } from 'react';
import { Card, Button, Input, Badge, toast } from '@cameleer/design-system';
import {
  useAccountMfaStatus,
  useAccountPasskeyList,
  useAccountRenamePasskey,
  useAccountDeletePasskey,
} from '../../api/account-hooks';
import { errorMessage } from '../../api/client';

function parseAgent(agent: string | null) {
  if (!agent) return 'Unknown device';
  const browserMatch = agent.match(/(Chrome|Firefox|Safari|Edge|Opera)\/[\d.]+/);
  const osMatch = agent.match(/(Windows|Mac OS X|Linux|Android|iOS)[\s/]?[\d._]*/);
  const browser = browserMatch ? browserMatch[1] : 'Unknown browser';
  const os = osMatch ? osMatch[0].replace(/_/g, '.') : '';
  return `${browser}${os ? ' on ' + os : ''}`;
}

export function PasskeyNudgeBanner() {
  const { data: mfaStatus } = useAccountMfaStatus();
  const [dismissed, setDismissed] = useState(() => {
    const val = localStorage.getItem('passkey_nudge_dismissed');
    if (!val) return false;
    return Date.now() - parseInt(val, 10) < 30 * 24 * 60 * 60 * 1000;
  });

  if (dismissed || !mfaStatus || mfaStatus.passkeyEnrolled) return null;

  return (
    <Card>
      <Card.Body>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
          <p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>
            Passkeys provide passwordless sign-in. Register one during your next sign-in.
          </p>
          <Button
            variant="ghost"
            size="sm"
            onClick={() => {
              localStorage.setItem('passkey_nudge_dismissed', String(Date.now()));
              setDismissed(true);
            }}
          >
            Dismiss
          </Button>
        </div>
      </Card.Body>
    </Card>
  );
}

export function PasskeySection() {
  const { data: passkeys, isLoading } = useAccountPasskeyList();
  const rename = useAccountRenamePasskey();
  const del = useAccountDeletePasskey();

  const [editingId, setEditingId] = useState<string | null>(null);
  const [editName, setEditName] = useState('');
  const [deletingId, setDeletingId] = useState<string | null>(null);

  if (isLoading) return null;
  if (!passkeys || passkeys.length === 0) return null;

  return (
    <Card>
      <Card.Header>
        <Card.Title>Passkeys</Card.Title>
      </Card.Header>
      <Card.Body>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          {passkeys.map((pk) => (
            <div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0',
              borderBottom: '1px solid var(--border)' }}>
              <div style={{ flex: 1 }}>
                {editingId === pk.id ? (
                  <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
                    <Input
                      value={editName}
                      onChange={(e) => setEditName(e.target.value)}
                      size="sm"
                      autoFocus
                    />
                    <Button size="sm" onClick={() => {
                      rename.mutate({ id: pk.id, name: editName }, {
                        onSuccess: () => { toast.success('Passkey renamed'); setEditingId(null); },
                        onError: (err) => toast.error(errorMessage(err)),
                      });
                    }} disabled={!editName.trim()} loading={rename.isPending}>Save</Button>
                    <Button size="sm" variant="ghost" onClick={() => setEditingId(null)}>Cancel</Button>
                  </div>
                ) : (
                  <>
                    <div style={{ fontWeight: 500, fontSize: 14 }}>{pk.name || 'Unnamed passkey'}</div>
                    <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
                      {parseAgent(pk.agent)}
                      {pk.createdAt && <> &middot; Added {new Date(pk.createdAt).toLocaleDateString()}</>}
                    </div>
                  </>
                )}
              </div>
              {editingId !== pk.id && (
                <div style={{ display: 'flex', gap: 4 }}>
                  <Button size="sm" variant="ghost" onClick={() => { setEditingId(pk.id); setEditName(pk.name || ''); }}>
                    Rename
                  </Button>
                  {deletingId === pk.id ? (
                    <>
                      <Button size="sm" variant="danger" onClick={() => {
                        del.mutate(pk.id, {
                          onSuccess: () => { toast.success('Passkey deleted'); setDeletingId(null); },
                          onError: (err) => toast.error(errorMessage(err)),
                        });
                      }} loading={del.isPending}>Confirm</Button>
                      <Button size="sm" variant="ghost" onClick={() => setDeletingId(null)}>Cancel</Button>
                    </>
                  ) : (
                    <Button size="sm" variant="ghost" onClick={() => setDeletingId(pk.id)}>Delete</Button>
                  )}
                </div>
              )}
            </div>
          ))}
        </div>
        <p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 12 }}>
          New passkeys are registered during sign-in.
        </p>
      </Card.Body>
    </Card>
  );
}
  • Step 5: Verify TypeScript compiles

Run: cd ui && npx tsc --noEmit 2>&1 | tail -10

Expected: No errors (note: some design-system components may differ in exact props — adapt during implementation)

  • Step 6: Commit
git add ui/src/components/account/
git commit -m "feat: extract shared account components (Profile, Password, MFA, Passkey)"

Task 11: Frontend — AccountSettingsPage

Files:

  • Create: ui/src/pages/AccountSettingsPage.tsx

  • Step 1: Create the page

Create ui/src/pages/AccountSettingsPage.tsx:

import { useNavigate } from 'react-router';
import { Button } from '@cameleer/design-system';
import { ArrowLeft } from 'lucide-react';
import { ProfileSection } from '../components/account/ProfileSection';
import { PasswordChangeSection } from '../components/account/PasswordChangeSection';
import { MfaSection } from '../components/account/MfaSection';
import { PasskeyNudgeBanner, PasskeySection } from '../components/account/PasskeySection';

export function AccountSettingsPage() {
  const navigate = useNavigate();

  return (
    <div style={{ maxWidth: 640, margin: '0 auto', padding: '24px 16px' }}>
      <div style={{ marginBottom: 24 }}>
        <Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
          <ArrowLeft size={14} style={{ marginRight: 4 }} /> Back
        </Button>
        <h1 style={{ fontSize: 24, fontWeight: 600, marginTop: 8 }}>Account Settings</h1>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
        <ProfileSection />
        <PasswordChangeSection />
        <MfaSection />
        <PasskeyNudgeBanner />
        <PasskeySection />
      </div>
    </div>
  );
}
  • Step 2: Commit
git add ui/src/pages/AccountSettingsPage.tsx
git commit -m "feat: add AccountSettingsPage composing shared account components"

Task 12: Frontend — Tenant SettingsPage Consolidation

Files:

  • Modify: ui/src/pages/tenant/SettingsPage.tsx

  • Modify: ui/src/api/tenant-hooks.ts

  • Step 1: Replace inline MFA/passkey/password components with shared imports

Read the full current SettingsPage.tsx first. Then replace the inline component definitions (MfaSection, PasskeySection, PasskeyNudgeBanner, and the password form) with imports from the shared components/account/ directory.

Remove:

  • 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:

import { MfaSection } from '../../components/account/MfaSection';
import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection';
import { PasswordChangeSection } from '../../components/account/PasswordChangeSection';

In the main SettingsPage component's return, replace the inline password form with <PasswordChangeSection />, and use the imported <MfaSection />, <PasskeyNudgeBanner />, <PasskeySection />.

Keep the tenant-specific sections inline: MfaEnforcementToggle, AuthPolicySection, server admin password form.

  • Step 2: Update tenant-hooks.ts — re-export MFA hooks from account-hooks

In ui/src/api/tenant-hooks.ts, replace the MFA and password hook definitions (lines 105-229) with re-exports:

// Re-export account hooks for backward compatibility
export {
  useAccountMfaStatus as useMfaStatus,
  useAccountMfaSetup as useMfaSetup,
  useAccountMfaVerify as useMfaVerify,
  useAccountBackupCodes as useMfaBackupCodes,
  useAccountMfaRemove as useMfaRemove,
  useAccountPasskeyList as usePasskeyList,
  useAccountRenamePasskey as useRenamePasskey,
  useAccountDeletePasskey as useDeletePasskey,
  useAccountMfaPreference as useUpdateMfaMethodPreference,
} from './account-hooks';

// Keep tenant-specific hooks
export function useResetServerAdminPassword() {
  return useMutation<void, Error, string>({
    mutationFn: (password) => api.post('/tenant/server/admin-password', { password }),
  });
}

export function useChangeOwnPassword() {
  return useMutation<void, Error, string>({
    mutationFn: (password) => api.post('/tenant/password', { password }),
  });
}

// ... keep useResetTeamMemberPassword, useResetTeamMemberMfa, useTenantSettings,
//     useUpdateTenantSettings, useTenantAuthSettings, useUpdateTenantAuthSettings

Remove the original definitions of useMfaStatus, useMfaSetup, useMfaVerify, useMfaBackupCodes, useMfaRemove, usePasskeyList, useRenamePasskey, useDeletePasskey, useUpdateMfaMethodPreference.

Keep useChangeOwnPassword and useResetServerAdminPassword — they call the old tenant endpoints which still work for the tenant admin use case (no current-password verification). The new PasswordChangeSection component uses the account hooks.

  • Step 3: Verify TypeScript compiles and nothing broke

Run: cd ui && npx tsc --noEmit 2>&1 | tail -10

Expected: No errors

  • Step 4: Commit
git add ui/src/pages/tenant/SettingsPage.tsx ui/src/api/tenant-hooks.ts
git commit -m "refactor: consolidate tenant SettingsPage to use shared account components"

Task 13: Frontend — VendorAdminsPage

Files:

  • Create: ui/src/pages/vendor/VendorAdminsPage.tsx

  • Step 1: Create the page

Create ui/src/pages/vendor/VendorAdminsPage.tsx:

import { useState } from 'react';
import { Card, Button, Input, FormField, Badge, toast, Dialog } from '@cameleer/design-system';
import { useVendorAdminList, useCreateVendorAdmin, useRemoveVendorAdmin, useResetVendorAdminPassword, useResetVendorAdminMfa } from '../../api/vendor-admin-hooks';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import { useAuth } from '../../auth/useAuth';
import { errorMessage } from '../../api/client';

export function VendorAdminsPage() {
  const { data: admins, isLoading } = useVendorAdminList();
  const createAdmin = useCreateVendorAdmin();
  const removeAdmin = useRemoveVendorAdmin();
  const resetPassword = useResetVendorAdminPassword();
  const resetMfa = useResetVendorAdminMfa();

  // Check if email connector is configured
  const { data: emailStatus } = useQuery({
    queryKey: ['vendor', 'email'],
    queryFn: () => api.get('/vendor/email').catch(() => null),
  });
  const emailConfigured = emailStatus != null;

  // Get current user's ID to prevent self-removal
  const { userId } = useAuth();

  // Dialog states
  const [showAdd, setShowAdd] = useState(false);
  const [addEmail, setAddEmail] = useState('');
  const [addPassword, setAddPassword] = useState('');
  const [createdResult, setCreatedResult] = useState<{ invited: boolean; tempPassword: string | null } | null>(null);

  const [resetPwUserId, setResetPwUserId] = useState<string | null>(null);
  const [resetPwValue, setResetPwValue] = useState('');

  const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null);
  const [confirmMfaResetId, setConfirmMfaResetId] = useState<string | null>(null);

  const handleCreate = () => {
    createAdmin.mutate(
      { email: addEmail, tempPassword: emailConfigured ? undefined : addPassword },
      {
        onSuccess: (result) => {
          setCreatedResult(result);
          if (result.invited) {
            toast.success('Invitation sent to ' + addEmail);
            setShowAdd(false);
            setAddEmail('');
          }
        },
        onError: (err) => toast.error(errorMessage(err)),
      }
    );
  };

  const handleRemove = (id: string) => {
    removeAdmin.mutate(id, {
      onSuccess: () => { toast.success('Administrator removed'); setConfirmRemoveId(null); },
      onError: (err) => toast.error(errorMessage(err)),
    });
  };

  const handleResetPassword = () => {
    if (!resetPwUserId) return;
    resetPassword.mutate({ userId: resetPwUserId, password: resetPwValue }, {
      onSuccess: () => { toast.success('Password reset'); setResetPwUserId(null); setResetPwValue(''); },
      onError: (err) => toast.error(errorMessage(err)),
    });
  };

  const handleResetMfa = (id: string) => {
    resetMfa.mutate(id, {
      onSuccess: () => { toast.success('MFA reset'); setConfirmMfaResetId(null); },
      onError: (err) => toast.error(errorMessage(err)),
    });
  };

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
        <h1 style={{ fontSize: 24, fontWeight: 600 }}>Platform Administrators</h1>
        <Button onClick={() => setShowAdd(true)}>Add Administrator</Button>
      </div>

      <Card>
        <Card.Body>
          {isLoading ? (
            <p style={{ color: 'var(--text-muted)' }}>Loading...</p>
          ) : !admins?.length ? (
            <p style={{ color: 'var(--text-muted)' }}>No administrators found.</p>
          ) : (
            <table style={{ width: '100%', borderCollapse: 'collapse' }}>
              <thead>
                <tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
                  <th style={{ padding: '8px 12px', fontSize: 13, fontWeight: 500, color: 'var(--text-muted)' }}>Name</th>
                  <th style={{ padding: '8px 12px', fontSize: 13, fontWeight: 500, color: 'var(--text-muted)' }}>Email</th>
                  <th style={{ padding: '8px 12px', fontSize: 13, fontWeight: 500, color: 'var(--text-muted)' }}></th>
                  <th style={{ padding: '8px 12px', fontSize: 13, fontWeight: 500, color: 'var(--text-muted)' }}>Actions</th>
                </tr>
              </thead>
              <tbody>
                {admins.map((admin) => {
                  const isSelf = admin.userId === userId;
                  return (
                    <tr key={admin.userId} style={{ borderBottom: '1px solid var(--border)' }}>
                      <td style={{ padding: '10px 12px', fontSize: 14 }}>{admin.name || '—'}</td>
                      <td style={{ padding: '10px 12px', fontSize: 14 }}>{admin.email || '—'}</td>
                      <td style={{ padding: '10px 12px' }}>
                        {isSelf && <Badge>You</Badge>}
                      </td>
                      <td style={{ padding: '10px 12px' }}>
                        <div style={{ display: 'flex', gap: 4 }}>
                          <Button size="sm" variant="ghost" onClick={() => { setResetPwUserId(admin.userId); setResetPwValue(''); }}>
                            Reset Password
                          </Button>
                          <Button size="sm" variant="ghost" onClick={() => setConfirmMfaResetId(admin.userId)}>
                            Reset MFA
                          </Button>
                          <Button size="sm" variant="ghost" disabled={isSelf}
                            onClick={() => setConfirmRemoveId(admin.userId)}>
                            Remove
                          </Button>
                        </div>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          )}
        </Card.Body>
      </Card>

      {/* Add Administrator Dialog */}
      {showAdd && (
        <Dialog open onClose={() => { setShowAdd(false); setAddEmail(''); setAddPassword(''); setCreatedResult(null); }}>
          <Dialog.Title>Add Administrator</Dialog.Title>
          <Dialog.Body>
            {createdResult && !createdResult.invited ? (
              <div>
                <p style={{ fontSize: 14, marginBottom: 12 }}>Administrator created. Share these credentials securely:</p>
                <div style={{ background: 'var(--bg-subtle)', padding: 12, borderRadius: 8, fontFamily: 'monospace', fontSize: 13 }}>
                  <div>Email: {addEmail}</div>
                  <div>Password: {createdResult.tempPassword}</div>
                </div>
                <Button size="sm" variant="ghost" style={{ marginTop: 8 }}
                  onClick={() => navigator.clipboard.writeText(`Email: ${addEmail}\nPassword: ${createdResult.tempPassword}`).then(() => toast.success('Copied'))}>
                  Copy
                </Button>
              </div>
            ) : (
              <>
                <FormField label="Email Address">
                  <Input
                    type="email"
                    value={addEmail}
                    onChange={(e) => setAddEmail(e.target.value)}
                    placeholder="admin@example.com"
                    autoFocus
                  />
                </FormField>
                {!emailConfigured && (
                  <>
                    <p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
                      Email connector not configured  set a temporary password.
                    </p>
                    <FormField label="Temporary Password">
                      <Input
                        type="password"
                        value={addPassword}
                        onChange={(e) => setAddPassword(e.target.value)}
                        placeholder="Minimum 8 characters"
                      />
                    </FormField>
                  </>
                )}
              </>
            )}
          </Dialog.Body>
          <Dialog.Footer>
            <Button variant="ghost" onClick={() => { setShowAdd(false); setAddEmail(''); setAddPassword(''); setCreatedResult(null); }}>
              {createdResult ? 'Close' : 'Cancel'}
            </Button>
            {!createdResult && (
              <Button onClick={handleCreate}
                disabled={!addEmail.includes('@') || (!emailConfigured && addPassword.length < 8) || createAdmin.isPending}
                loading={createAdmin.isPending}>
                {emailConfigured ? 'Send Invite' : 'Create'}
              </Button>
            )}
          </Dialog.Footer>
        </Dialog>
      )}

      {/* Reset Password Dialog */}
      {resetPwUserId && (
        <Dialog open onClose={() => { setResetPwUserId(null); setResetPwValue(''); }}>
          <Dialog.Title>Reset Password</Dialog.Title>
          <Dialog.Body>
            <FormField label="New Password">
              <Input
                type="password"
                value={resetPwValue}
                onChange={(e) => setResetPwValue(e.target.value)}
                placeholder="Minimum 8 characters"
                autoFocus
              />
            </FormField>
          </Dialog.Body>
          <Dialog.Footer>
            <Button variant="ghost" onClick={() => { setResetPwUserId(null); setResetPwValue(''); }}>Cancel</Button>
            <Button onClick={handleResetPassword} disabled={resetPwValue.length < 8 || resetPassword.isPending}
              loading={resetPassword.isPending}>
              Reset
            </Button>
          </Dialog.Footer>
        </Dialog>
      )}

      {/* Confirm Remove Dialog */}
      {confirmRemoveId && (
        <Dialog open onClose={() => setConfirmRemoveId(null)}>
          <Dialog.Title>Remove Administrator</Dialog.Title>
          <Dialog.Body>
            <p>Remove this user as platform administrator? They will lose access to the vendor console.</p>
          </Dialog.Body>
          <Dialog.Footer>
            <Button variant="ghost" onClick={() => setConfirmRemoveId(null)}>Cancel</Button>
            <Button variant="danger" onClick={() => handleRemove(confirmRemoveId)} loading={removeAdmin.isPending}>
              Remove
            </Button>
          </Dialog.Footer>
        </Dialog>
      )}

      {/* Confirm MFA Reset Dialog */}
      {confirmMfaResetId && (
        <Dialog open onClose={() => setConfirmMfaResetId(null)}>
          <Dialog.Title>Reset MFA</Dialog.Title>
          <Dialog.Body>
            <p>Reset all MFA enrollments for this administrator? They will need to re-enroll.</p>
          </Dialog.Body>
          <Dialog.Footer>
            <Button variant="ghost" onClick={() => setConfirmMfaResetId(null)}>Cancel</Button>
            <Button variant="danger" onClick={() => handleResetMfa(confirmMfaResetId)} loading={resetMfa.isPending}>
              Reset MFA
            </Button>
          </Dialog.Footer>
        </Dialog>
      )}
    </div>
  );
}

Note: The Dialog component usage should match the design system's API. Read the design system's Dialog type definitions during implementation and adapt if the prop names differ (e.g., isOpen vs open, onDismiss vs onClose). Also check whether useAuth() exposes userId — if not, extract it from the JWT token or the /api/me response.

  • Step 2: Verify TypeScript compiles

Run: cd ui && npx tsc --noEmit 2>&1 | tail -10

Expected: No errors

  • Step 3: Commit
git add ui/src/pages/vendor/VendorAdminsPage.tsx
git commit -m "feat: add VendorAdminsPage with list, create/invite, remove, reset actions"

Task 14: Frontend — Layout User Menu + Router

Files:

  • Modify: ui/src/components/Layout.tsx

  • Modify: ui/src/router.tsx

  • Step 1: Add user menu item to Layout TopBar

In Layout.tsx, add userMenuItems prop to the <TopBar> component (line 240). Import Settings from lucide-react (already imported), and useNavigate (already imported).

Add before the return statement (inside Layout function):

const userMenuItems = [
  {
    label: 'Account Settings',
    icon: <Settings size={14} />,
    onClick: () => navigate('/settings/account'),
  },
];

Update the TopBar JSX:

<TopBar
  breadcrumb={breadcrumb}
  user={username ? { name: username } : undefined}
  userMenuItems={userMenuItems}
  onLogout={logout}
/>
  • Step 2: Add Administrators to vendor sidebar

In Layout.tsx, add a new sidebar item for "Administrators" in the vendor section. Add after the "Auth Policy" item (~line 158) and before the "Logto Console" item (~line 160):

<div
  style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
    fontWeight: isActive(location, '/vendor/admins') ? 600 : 400,
    color: isActive(location, '/vendor/admins') ? 'var(--amber)' : 'var(--text-muted)' }}
  onClick={() => navigate('/vendor/admins')}
>
  <Users size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
  Administrators
</div>

Note: Users icon is already imported.

  • Step 3: Add routes to router.tsx

In router.tsx, add two new routes:

  1. Add import at top:
import { AccountSettingsPage } from './pages/AccountSettingsPage';
import { VendorAdminsPage } from './pages/vendor/VendorAdminsPage';
  1. 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:
<Route path="settings/account" element={<AccountSettingsPage />} />

Note: This must be inside ProtectedRoute but NOT inside Layout (the page provides its own header). Check the exact nesting during implementation — if the account page should still show the sidebar, put it inside Layout instead.

  1. Add /vendor/admins route inside the vendor route group (after /vendor/auth-policy):
<Route path="vendor/admins" element={<VendorAdminsPage />} />
  • Step 4: Verify TypeScript compiles

Run: cd ui && npx tsc --noEmit 2>&1 | tail -10

Expected: No errors

  • Step 5: Commit
git add ui/src/components/Layout.tsx ui/src/router.tsx
git commit -m "feat: add account settings route, vendor admins route, and user menu dropdown"

Files:

  • Review: ui/sign-in/src/SignInPage.tsx

  • Step 1: Verify the forgot password link is already visible

Read SignInPage.tsx around lines 354-362. The "Forgot password?" button already exists, conditionally rendered when emailConnectorConfigured is true:

{emailConnectorConfigured && (
  <button type="button" className={styles.forgotLink}
    onClick={() => { setError(null); setMode('forgotPassword'); }}>
    Forgot password?
  </button>
)}

This is correct behavior — the forgot password flow requires the email connector to send a verification code. When email is not configured, the link correctly hides since the flow would fail.

No code changes needed. The forgot password link is already implemented and visible when the email connector is active. The full flow (send code → verify + reset → notification email) is already wired.

  • Step 2: Commit (skip — no changes)

No commit needed.


Task 16: Smoke Test

  • Step 1: Start the dev environment

Run: docker compose up -d (or however the dev environment starts)

  • Step 2: Test account settings
  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.