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