Merge pull request 'feat: vendor admin management and shared account settings' (#59) from feature/vendor-admin-account-settings into main
All checks were successful
CI / build (push) Successful in 2m15s
CI / docker (push) Successful in 20s

Reviewed-on: #59
This commit was merged in pull request #59.
This commit is contained in:
2026-04-27 15:20:23 +02:00
28 changed files with 4586 additions and 697 deletions

View File

@@ -27,9 +27,10 @@ Agent-server protocol is defined in `cameleer/cameleer-common/PROTOCOL.md`. The
|---------|---------|-------------|
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService`, `EmailConnectorService`, `EmailConnectorController`, `VendorAuthPolicyController`, `VendorAuthPolicyEntity` |
| `account/` | Shared user account operations | `AccountService` (profile, password, MFA, passkeys), `AccountController` (`/api/account/*`) |
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService`, `EmailConnectorService`, `EmailConnectorController`, `VendorAuthPolicyController`, `VendorAuthPolicyEntity`, `VendorAdminService`, `VendorAdminController` |
| `onboarding/` | Self-service sign-up onboarding | `OnboardingController`, `OnboardingService` |
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService` (delegates user-level ops to AccountService), `TenantPortalController` |
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
| `license/` | License management | `LicenseService`, `LicenseController` |
@@ -81,7 +82,7 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-saas** (3330 symbols, 7090 relationships, 281 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **vendor-admin-account** (3510 symbols, 7678 relationships, 298 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -97,7 +98,7 @@ This project is indexed by GitNexus as **cameleer-saas** (3330 symbols, 7090 rel
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/cameleer-saas/process/{processName}` — trace the full execution flow step by step
3. `READ gitnexus://repo/vendor-admin-account/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
@@ -136,10 +137,10 @@ This project is indexed by GitNexus as **cameleer-saas** (3330 symbols, 7090 rel
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/cameleer-saas/context` | Codebase overview, check index freshness |
| `gitnexus://repo/cameleer-saas/clusters` | All functional areas |
| `gitnexus://repo/cameleer-saas/processes` | All execution flows |
| `gitnexus://repo/cameleer-saas/process/{name}` | Step-by-step execution trace |
| `gitnexus://repo/vendor-admin-account/context` | Codebase overview, check index freshness |
| `gitnexus://repo/vendor-admin-account/clusters` | All functional areas |
| `gitnexus://repo/vendor-admin-account/processes` | All execution flows |
| `gitnexus://repo/vendor-admin-account/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,299 @@
# Vendor Admin Management & Account Settings
**Date:** 2026-04-27
**Status:** Approved
## Problem
1. The vendor console supports only a single platform admin (created during bootstrap via `SAAS_ADMIN_USER`). There is no way to add additional administrators.
2. There is no page where any authenticated user (vendor or tenant) can manage their own account — display name, password, MFA enrollment.
## Design Decisions
- **Flat admin model** — all vendor admins are equal (`platform:admin`), no tiers
- **Invite or create** — invite via email when connector is configured, create with temporary credentials when it's not
- **Shared account settings** — single `/settings/account` route for any authenticated user (vendor or tenant), reached via user menu in header
- **Password change requires current password** — verified via ROPC token exchange against Logto
- **Confirmation email** on any successful password change (uses existing `PasswordResetNotificationService`)
- **Forgot password link** on sign-in page (flow already implemented, just needs visible link)
- **MFA self-service** — TOTP setup/removal, backup codes, passkey list/rename/delete. Passkey registration remains in sign-in flow (Logto Experience API).
---
## Section 1: Backend — New `account/` Package
### AccountService
New service at `src/main/java/net/siegeln/cameleer/saas/account/AccountService.java`.
Extracts user-level identity operations from `TenantPortalService`:
| Method | Extracted from | Logto API |
|--------|---------------|-----------|
| `getProfile(userId)` | New | `GET /api/users/{userId}` |
| `updateDisplayName(userId, name)` | `OnboardingService` | `PATCH /api/users/{userId}` |
| `changePassword(userId, currentPassword, newPassword)` | `TenantPortalService.changePassword()` | ROPC verify + `PATCH /api/users/{userId}/password` |
| `validatePassword(password)` | Duplicated in 3+ places | Local validation (min 8 chars) |
| `getMfaStatus(userId)` | `TenantPortalService.getMfaStatus()` | `GET /api/users/{userId}/mfa-verifications` |
| `setupTotp(userId)` | `TenantPortalService.setupTotp()` | `POST /api/users/{userId}/mfa-verifications` |
| `verifyTotpCode(secret, code)` | `TenantPortalService.verifyTotpCode()` | Local HMAC-SHA1 computation |
| `generateBackupCodes(userId)` | `TenantPortalService.generateBackupCodes()` | `POST /api/users/{userId}/mfa-verifications` |
| `removeMfa(userId)` | `TenantPortalService.removeTotp()` | Batch `DELETE /api/users/{userId}/mfa-verifications/{id}` |
| `listPasskeys(userId)` | `TenantPortalService.listPasskeys()` | `GET /api/users/{userId}/mfa-verifications` (filtered) |
| `renamePasskey(userId, id, name)` | `TenantPortalService.renamePasskey()` | `PATCH /api/users/{userId}/mfa-verifications/{id}` |
| `deletePasskey(userId, id)` | `TenantPortalService.deletePasskey()` | `DELETE /api/users/{userId}/mfa-verifications/{id}` |
| `setMfaMethodPreference(userId, pref)` | `TenantPortalService.updateMfaMethodPreference()` | `PATCH /api/users/{userId}/custom-data` |
TOTP helper methods (`computeTotp`, base32 encoding, HMAC-SHA1) move with the service.
### AccountController
New controller at `src/main/java/net/siegeln/cameleer/saas/account/AccountController.java`.
All endpoints require `authenticated()` (any logged-in user, no scope check). User ID extracted from JWT `sub` claim.
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `GET /api/account/profile` | GET | Own display name + email |
| `PATCH /api/account/profile` | PATCH | Update display name |
| `POST /api/account/password` | POST | Change password (current + new) |
| `GET /api/account/mfa/status` | GET | MFA enrollment status |
| `POST /api/account/mfa/totp/setup` | POST | Start TOTP enrollment |
| `POST /api/account/mfa/totp/verify` | POST | Verify TOTP code |
| `POST /api/account/mfa/backup-codes` | POST | Generate backup codes |
| `DELETE /api/account/mfa/totp` | DELETE | Remove TOTP |
| `GET /api/account/mfa/webauthn` | GET | List passkeys |
| `PATCH /api/account/mfa/webauthn/{id}/name` | PATCH | Rename passkey |
| `DELETE /api/account/mfa/webauthn/{id}` | DELETE | Delete passkey |
| `POST /api/account/mfa/method-preference` | POST | Set MFA preference |
### SecurityConfig Changes
Add to the security filter chain:
- `/api/account/**``authenticated()` (any logged-in user)
- `/api/account/mfa/**` exempt from `MfaEnforcementFilter` (same as current `/api/tenant/mfa/` exemption)
### TenantPortalService Consolidation
After extraction, `TenantPortalService` delegates to `AccountService` for user-level operations:
- `changePassword()``accountService.changePassword()`
- `getMfaStatus()``accountService.getMfaStatus()`
- `setupTotp()``accountService.setupTotp()`
- `verifyTotpCode()``accountService.verifyTotpCode()`
- `generateBackupCodes()``accountService.generateBackupCodes()`
- `removeTotp()``accountService.removeMfa()`
- `listPasskeys()``accountService.listPasskeys()`
- `renamePasskey()``accountService.renamePasskey()`
- `deletePasskey()``accountService.deletePasskey()`
- `updateMfaMethodPreference()``accountService.setMfaMethodPreference()`
Tenant-admin operations stay in `TenantPortalService`: `resetTeamMemberMfa`, `resetTeamMemberPassword`, `updateTenantSettings`, `getAuthSettings`, `resetServerAdminPassword`.
Old `/api/tenant/mfa/*` and `/api/tenant/password` endpoints remain as thin delegates to preserve backward compatibility during migration, then deprecated.
### Password Change Flow
1. Client sends `POST /api/account/password` with `{ currentPassword, newPassword }`
2. `AccountService.changePassword()`:
a. `validatePassword(newPassword)` — min 8 chars
b. Fetch user email via `logtoClient.getUser(userId)`
c. Attempt ROPC token exchange: `POST /oidc/token` with `grant_type=password`, user's email + `currentPassword` against the SaaS OIDC app
d. If token exchange fails → 400 "Current password is incorrect"
e. `logtoClient.updateUserPassword(userId, newPassword)`
f. Fire `passwordResetNotificationService.sendNotification(email)` asynchronously
### LogtoManagementClient — ROPC Addition
New method: `verifyPasswordViaRopc(String email, String password)` — attempts password grant against Logto's token endpoint using the SaaS app's client ID. Returns boolean (success/failure). Does not store the returned token.
**Prerequisite:** The SaaS OIDC application in Logto must have the Resource Owner Password Credentials (ROPC) grant type enabled. This is configured in `logto-bootstrap.sh` when creating the application (add `password` to the `grantTypes` array if not already present).
---
## Section 2: Vendor Admin Management
### VendorAdminService
New service at `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java`.
| Method | Purpose | Logto API |
|--------|---------|-----------|
| `listAdmins()` | List all users with `saas-vendor` role | `GET /api/roles/{roleId}/users` |
| `createAdmin(email, tempPassword?)` | Create + assign role (invite or credentials) | `POST /api/users` + `POST /api/users/{id}/roles` |
| `removeAdmin(userId, requesterId)` | Revoke role (blocks self-removal) | `DELETE /api/users/{id}/roles/{roleId}` |
| `resetAdminPassword(userId, newPassword)` | Reset password + send notification | `PATCH /api/users/{id}/password` |
| `resetAdminMfa(userId)` | Delete all MFA verifications | Batch `DELETE /api/users/{id}/mfa-verifications/{vid}` |
**Create admin logic:**
- Check `emailConnectorService.isEmailConnectorConfigured()`
- If configured and no temp password provided: `logtoClient.createAndInviteUser(email)` + assign `saas-vendor` role → returns `{ invited: true }`
- If not configured or temp password provided: `logtoClient.createUserWithPassword(email, tempPassword)` + assign `saas-vendor` role → returns `{ invited: false, tempPassword }`
**Self-removal prevention:** `removeAdmin` compares `userId` with `requesterId` (from JWT `sub`). Throws 400 if equal.
### VendorAdminController
New controller at `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java`. All endpoints require `platform:admin`.
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `GET /api/vendor/admins` | GET | List all vendor admins |
| `POST /api/vendor/admins` | POST | Create/invite new admin |
| `DELETE /api/vendor/admins/{userId}` | DELETE | Remove admin |
| `POST /api/vendor/admins/{userId}/reset-password` | POST | Reset another admin's password |
| `DELETE /api/vendor/admins/{userId}/mfa` | DELETE | Reset another admin's MFA |
### LogtoManagementClient Additions
| Method | Logto API |
|--------|-----------|
| `listRoleUsers(roleId)` | `GET /api/roles/{roleId}/users` |
| `assignGlobalRole(userId, roleId)` | `POST /api/users/{userId}/roles` |
| `revokeGlobalRole(userId, roleId)` | `DELETE /api/users/{userId}/roles/{roleId}` |
| `getRoleByName(roleName)` | `GET /api/roles?search={name}` |
---
## Section 3: Frontend — Shared Account Settings Page
### New Route
`/settings/account` — standalone page with app header and back navigation. Accessible to any authenticated user.
### Extracted Components
Components extracted from `SettingsPage.tsx` into `ui/src/components/account/`:
| Component | Source | File |
|-----------|--------|------|
| `ProfileSection` | New | `ProfileSection.tsx` |
| `PasswordChangeSection` | `SettingsPage.tsx` lines 631-664 | `PasswordChangeSection.tsx` |
| `MfaSection` | `SettingsPage.tsx` lines 34-270 | `MfaSection.tsx` |
| `PasskeySection` | `SettingsPage.tsx` lines 368-462 | `PasskeySection.tsx` |
The `PasswordChangeSection` gains a "current password" field (the existing tenant version only has new password + confirm).
### New Account Settings Page
`ui/src/pages/AccountSettingsPage.tsx` — composes all four sections in order: Profile, Password, MFA, Passkeys.
### Tenant SettingsPage Consolidation
`SettingsPage.tsx` imports the shared components from `ui/src/components/account/` instead of defining them inline. Keeps its tenant-specific sections: `AuthPolicySection`, `MfaEnforcementToggle`, server admin password reset.
### New API Hooks
`ui/src/hooks/account-hooks.ts`:
| Hook | Endpoint |
|------|----------|
| `useAccountProfile()` | `GET /api/account/profile` |
| `useUpdateDisplayName()` | `PATCH /api/account/profile` |
| `useChangePassword()` | `POST /api/account/password` |
| `useAccountMfaStatus()` | `GET /api/account/mfa/status` |
| `useAccountMfaSetup()` | `POST /api/account/mfa/totp/setup` |
| `useAccountMfaVerify()` | `POST /api/account/mfa/totp/verify` |
| `useAccountBackupCodes()` | `POST /api/account/mfa/backup-codes` |
| `useAccountMfaRemove()` | `DELETE /api/account/mfa/totp` |
| `useAccountPasskeyList()` | `GET /api/account/mfa/webauthn` |
| `useAccountRenamePasskey()` | `PATCH /api/account/mfa/webauthn/{id}/name` |
| `useAccountDeletePasskey()` | `DELETE /api/account/mfa/webauthn/{id}` |
| `useAccountMfaPreference()` | `POST /api/account/mfa/method-preference` |
Old hooks in `tenant-hooks.ts` (`useMfaStatus`, `useMfaSetup`, etc.) are replaced with re-exports from `account-hooks.ts` to avoid breaking any remaining references during migration.
### User Menu in Header
Dropdown on the user name/avatar in the app header:
- "Account Settings" → `/settings/account`
- "Sign Out" → existing sign-out flow
---
## Section 4: Vendor Admin List Page
### New Route
`/vendor/admins` — added to vendor sidebar navigation.
### Page Layout
- Header: "Platform Administrators" + "Add Administrator" button
- Table columns: Display Name, Email, Status ("You" badge on self-row), actions
- Row actions (kebab menu): Reset Password, Reset MFA, Remove
- Self-row: remove action disabled
### Add Administrator Dialog
- Checks email connector status via existing `GET /api/vendor/email/status`
- Email connector configured: single email field → invite sent
- Email connector not configured: email field + temporary password field → credentials shown with copy action on success
### Confirmation Dialogs
- Remove: "Remove {name} as platform administrator? They will lose access to the vendor console."
- Reset Password: form with new temporary password field
- Reset MFA: "Reset all MFA enrollments for {name}? They will need to re-enroll."
### New API Hooks
`ui/src/hooks/vendor-admin-hooks.ts`:
| Hook | Endpoint |
|------|----------|
| `useVendorAdminList()` | `GET /api/vendor/admins` |
| `useCreateVendorAdmin()` | `POST /api/vendor/admins` |
| `useRemoveVendorAdmin()` | `DELETE /api/vendor/admins/{userId}` |
| `useResetVendorAdminPassword()` | `POST /api/vendor/admins/{userId}/reset-password` |
| `useResetVendorAdminMfa()` | `DELETE /api/vendor/admins/{userId}/mfa` |
---
## Section 5: Sign-In Page Changes
### Forgot Password Link
Add a "Forgot password?" link below the password field in `SignInPage.tsx`. This triggers the existing `forgotPassword` mode (already implemented at lines 187-238). The flow:
1. User enters email
2. Logto Experience API sends reset code
3. User enters code + new password
4. On success, fires `POST /api/password-reset-notification` (already wired)
No backend changes needed — the flow exists, just needs a visible trigger in the UI.
---
## Files Changed Summary
### New Files
| File | Purpose |
|------|---------|
| `src/.../account/AccountService.java` | User-level identity operations |
| `src/.../account/AccountController.java` | `/api/account/*` endpoints |
| `src/.../vendor/VendorAdminService.java` | Vendor admin CRUD |
| `src/.../vendor/VendorAdminController.java` | `/api/vendor/admins/*` endpoints |
| `ui/src/components/account/ProfileSection.tsx` | Display name + email |
| `ui/src/components/account/PasswordChangeSection.tsx` | Password change form |
| `ui/src/components/account/MfaSection.tsx` | TOTP management |
| `ui/src/components/account/PasskeySection.tsx` | Passkey list/rename/delete |
| `ui/src/pages/AccountSettingsPage.tsx` | Shared account settings page |
| `ui/src/pages/vendor/VendorAdminsPage.tsx` | Vendor admin list page |
| `ui/src/hooks/account-hooks.ts` | Shared account API hooks |
| `ui/src/hooks/vendor-admin-hooks.ts` | Vendor admin API hooks |
### Modified Files
| File | Change |
|------|--------|
| `LogtoManagementClient.java` | Add `verifyPasswordViaRopc`, `listRoleUsers`, `assignGlobalRole`, `revokeGlobalRole`, `getRoleByName` |
| `SecurityConfig.java` | Add `/api/account/**` as `authenticated()` |
| `MfaEnforcementFilter.java` | Exempt `/api/account/mfa/` paths |
| `TenantPortalService.java` | Delegate MFA/password/passkey methods to `AccountService` |
| `TenantPortalController.java` | Optionally deprecate old `/api/tenant/mfa/*` endpoints |
| `OnboardingService.java` | Use `AccountService.updateDisplayName()` instead of direct Logto call |
| `SettingsPage.tsx` (tenant) | Import shared components from `components/account/` |
| `tenant-hooks.ts` | Replace MFA/password hooks with re-exports from `account-hooks.ts` |
| `router.tsx` | Add `/settings/account` and `/vendor/admins` routes |
| `SignInPage.tsx` | Add "Forgot password?" link |
| App header component | Add user dropdown menu |

View File

@@ -0,0 +1,114 @@
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());
return Map.of("verified", ok);
}
@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();
}
}

View File

@@ -0,0 +1,240 @@
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) {
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;
}
}

View File

@@ -26,6 +26,9 @@ public class MfaEnforcementFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class);
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",

View File

@@ -45,10 +45,11 @@ public class SecurityConfig {
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/config").permitAll()
.requestMatchers("/", "/index.html", "/login", "/register", "/callback",
"/vendor/**", "/tenant/**", "/onboarding",
"/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()

View File

@@ -222,9 +222,11 @@ public class LogtoManagementClient {
.retrieve()
.body(Map.class);
String userId = String.valueOf(userResp.get("id"));
addUserToOrganization(orgId, userId);
if (roleId != null) {
assignOrganizationRole(orgId, userId, roleId);
if (orgId != null) {
addUserToOrganization(orgId, userId);
if (roleId != null) {
assignOrganizationRole(orgId, userId, roleId);
}
}
return userId;
} catch (Exception e) {
@@ -233,7 +235,7 @@ public class LogtoManagementClient {
}
}
/** Create a user with username/password and add to org with role. */
/** Create a user with username/password and optionally add to org with role. */
@SuppressWarnings("unchecked")
public String createUserWithPassword(String username, String password, String orgId, String roleId) {
if (!isAvailable()) return null;
@@ -246,9 +248,11 @@ public class LogtoManagementClient {
.retrieve()
.body(Map.class);
String userId = String.valueOf(userResp.get("id"));
addUserToOrganization(orgId, userId);
if (roleId != null) {
assignOrganizationRole(orgId, userId, roleId);
if (orgId != null) {
addUserToOrganization(orgId, userId);
if (roleId != null) {
assignOrganizationRole(orgId, userId, roleId);
}
}
log.info("Created user '{}' and added to org {} with role {}", username, orgId, roleId);
return userId;
@@ -526,6 +530,26 @@ public class LogtoManagementClient {
.toBodilessEntity();
}
/** Verify a user's current password. Returns true if correct, false if wrong. */
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(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;
}
}
// --- MFA Verification Management ---
/** List all MFA verifications for a user. Returns a list of MFA factor objects. */
@@ -673,6 +697,60 @@ public class LogtoManagementClient {
}
}
// --- Global Role Management ---
/** 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 exact name. Returns null if not found. */
@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(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();
}
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
private synchronized String getAccessToken() {

View File

@@ -1,5 +1,6 @@
package net.siegeln.cameleer.saas.onboarding;
import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
@@ -8,7 +9,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.UUID;
/**
@@ -23,11 +23,14 @@ public class OnboardingService {
private final VendorTenantService vendorTenantService;
private final LogtoManagementClient logtoClient;
private final AccountService accountService;
public OnboardingService(VendorTenantService vendorTenantService,
LogtoManagementClient logtoClient) {
LogtoManagementClient logtoClient,
AccountService accountService) {
this.vendorTenantService = vendorTenantService;
this.logtoClient = logtoClient;
this.accountService = accountService;
}
public TenantEntity createTrialTenant(String name, String slug, String logtoUserId) {
@@ -55,12 +58,12 @@ public class OnboardingService {
log.info("Added user {} as owner of tenant {}", logtoUserId, slug);
// Set display name from email if not already set (email-registered users have no name)
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", ""));
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('@'));
logtoClient.updateUserProfile(logtoUserId, Map.of("name", displayName));
accountService.updateDisplayName(logtoUserId, displayName);
log.info("Set display name '{}' for user {}", displayName, logtoUserId);
}
}

View File

@@ -107,12 +107,8 @@ public class TenantPortalController {
@PostMapping("/password")
public ResponseEntity<Void> changeOwnPassword(@AuthenticationPrincipal Jwt jwt,
@RequestBody PasswordChangeRequest body) {
try {
portalService.changePassword(jwt.getSubject(), body.password());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
portalService.changePassword(jwt.getSubject(), body.password());
return ResponseEntity.noContent().build();
}
@PostMapping("/team/{userId}/password")
@@ -203,23 +199,15 @@ public class TenantPortalController {
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().build();
}
try {
portalService.renamePasskey(jwt.getSubject(), id, name);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
portalService.renamePasskey(jwt.getSubject(), id, name);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/mfa/webauthn/{id}")
public ResponseEntity<Void> deletePasskey(@AuthenticationPrincipal Jwt jwt,
@PathVariable String id) {
try {
portalService.deletePasskey(jwt.getSubject(), id);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
portalService.deletePasskey(jwt.getSubject(), id);
return ResponseEntity.noContent().build();
}
@PostMapping("/mfa/method-preference")

View File

@@ -1,5 +1,6 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.config.TenantContext;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient;
@@ -15,10 +16,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
@@ -39,6 +36,7 @@ public class TenantPortalService {
private final TenantProvisioner tenantProvisioner;
private final ProvisioningProperties provisioningProps;
private final VendorTenantService vendorTenantService;
private final AccountService accountService;
public TenantPortalService(TenantService tenantService,
LicenseService licenseService,
@@ -46,7 +44,8 @@ public class TenantPortalService {
LogtoManagementClient logtoClient,
TenantProvisioner tenantProvisioner,
ProvisioningProperties provisioningProps,
@Lazy VendorTenantService vendorTenantService) {
@Lazy VendorTenantService vendorTenantService,
AccountService accountService) {
this.tenantService = tenantService;
this.licenseService = licenseService;
this.serverApiClient = serverApiClient;
@@ -54,6 +53,7 @@ public class TenantPortalService {
this.tenantProvisioner = tenantProvisioner;
this.provisioningProps = provisioningProps;
this.vendorTenantService = vendorTenantService;
this.accountService = accountService;
}
// --- Inner records ---
@@ -221,9 +221,7 @@ public class TenantPortalService {
}
public void changePassword(String userId, String newPassword) {
if (newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
accountService.validatePassword(newPassword);
logtoClient.updateUserPassword(userId, newPassword);
}
@@ -293,69 +291,26 @@ public class TenantPortalService {
// --- MFA methods ---
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);
var data = accountService.getMfaStatus(userId);
return new MfaStatusData(data.enrolled(), data.hasBackupCodes(), data.passkeyEnrolled(), data.passkeyCount());
}
@SuppressWarnings("unchecked")
public MfaSetupData setupTotp(String userId) {
byte[] secretBytes = new byte[20];
new SecureRandom().nextBytes(secretBytes);
String secret = base32Encode(secretBytes);
var response = logtoClient.createTotpVerification(userId, secret);
String qrCode = "";
if (response.containsKey("secretQrCode")) {
qrCode = String.valueOf(response.get("secretQrCode"));
} else if (response.containsKey("qrCode")) {
qrCode = String.valueOf(response.get("qrCode"));
}
return new MfaSetupData(secret, qrCode);
var data = accountService.setupTotp(userId);
return new MfaSetupData(data.secret(), data.secretQrCode());
}
public boolean verifyTotpCode(String secret, String code) {
if (secret == null || code == null || code.length() != 6) {
return false;
}
long currentTimeStep = System.currentTimeMillis() / 30000;
// Allow +-1 step drift
for (long step = currentTimeStep - 1; step <= currentTimeStep + 1; step++) {
String computed = computeTotp(secret, step);
if (computed.equals(code)) {
return true;
}
}
return false;
return accountService.verifyTotpCode(secret, code);
}
@SuppressWarnings("unchecked")
public BackupCodesData generateBackupCodes(String userId) {
var response = logtoClient.createBackupCodes(userId);
List<String> codes = List.of();
if (response.containsKey("codes")) {
var rawCodes = response.get("codes");
if (rawCodes instanceof List) {
codes = ((List<Object>) rawCodes).stream()
.map(String::valueOf)
.toList();
}
}
return new BackupCodesData(codes);
var data = accountService.generateBackupCodes(userId);
return new BackupCodesData(data.codes());
}
public void removeTotp(String userId) {
var verifications = logtoClient.getUserMfaVerifications(userId);
for (var v : verifications) {
String id = String.valueOf(v.get("id"));
logtoClient.deleteMfaVerification(userId, id);
}
accountService.removeMfa(userId);
}
public void resetTeamMemberMfa(String userId) {
@@ -378,40 +333,22 @@ public class TenantPortalService {
public record PasskeyCredential(String id, String name, String agent, String createdAt) {}
@SuppressWarnings("unchecked")
public List<PasskeyCredential> listPasskeys(String userId) {
return logtoClient.getWebAuthnCredentials(userId).stream()
.map(v -> new PasskeyCredential(
String.valueOf(v.get("id")),
v.get("name") != null ? String.valueOf(v.get("name")) : null,
v.get("agent") != null ? String.valueOf(v.get("agent")) : null,
v.get("createdAt") != null ? String.valueOf(v.get("createdAt")) : null
))
return accountService.listPasskeys(userId).stream()
.map(p -> new PasskeyCredential(p.id(), p.name(), p.agent(), p.createdAt()))
.toList();
}
public void renamePasskey(String userId, String credentialId, String name) {
var credentials = logtoClient.getWebAuthnCredentials(userId);
boolean owns = credentials.stream()
.anyMatch(v -> credentialId.equals(String.valueOf(v.get("id"))));
if (!owns) {
throw new IllegalArgumentException("Credential not found");
}
logtoClient.renameMfaVerification(userId, credentialId, name);
accountService.renamePasskey(userId, credentialId, name);
}
public void deletePasskey(String userId, String credentialId) {
var credentials = logtoClient.getWebAuthnCredentials(userId);
boolean owns = credentials.stream()
.anyMatch(v -> credentialId.equals(String.valueOf(v.get("id"))));
if (!owns) {
throw new IllegalArgumentException("Credential not found");
}
logtoClient.deleteMfaVerification(userId, credentialId);
accountService.deletePasskey(userId, credentialId);
}
public void updateMfaMethodPreference(String userId, String preference) {
logtoClient.updateUserCustomData(userId, Map.of("mfa_method_preference", preference));
accountService.setMfaMethodPreference(userId, preference);
}
public void updateTenantSettings(Map<String, Object> updates) {
@@ -455,70 +392,4 @@ public class TenantPortalService {
return new AuthSettingsData(mfaMode, passkeyEnabled, passkeyMode);
}
// --- TOTP helpers ---
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 binary = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
int otp = binary % 1_000_000;
return String.format("%06d", otp);
} catch (Exception e) {
log.error("TOTP computation failed", e);
return "";
}
}
private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private String base32Encode(byte[] data) {
StringBuilder result = new StringBuilder();
int buffer = 0;
int bitsLeft = 0;
for (byte b : data) {
buffer = (buffer << 8) | (b & 0xFF);
bitsLeft += 8;
while (bitsLeft >= 5) {
result.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
bitsLeft -= 5;
}
}
if (bitsLeft > 0) {
result.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F));
}
return result.toString();
}
private byte[] base32Decode(String encoded) {
String upper = encoded.toUpperCase().replaceAll("[=\\s]", "");
int[] output = new int[upper.length() * 5 / 8];
int buffer = 0;
int bitsLeft = 0;
int index = 0;
for (char c : upper.toCharArray()) {
int val = BASE32_ALPHABET.indexOf(c);
if (val < 0) continue;
buffer = (buffer << 5) | val;
bitsLeft += 5;
if (bitsLeft >= 8) {
output[index++] = (buffer >> (bitsLeft - 8)) & 0xFF;
bitsLeft -= 8;
}
}
byte[] result = new byte[index];
for (int i = 0; i < index; i++) {
result[i] = (byte) output[i];
}
return result;
}
}

View File

@@ -0,0 +1,53 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.vendor.VendorAdminService.CreateAdminRequest;
import net.siegeln.cameleer.saas.vendor.VendorAdminService.CreateAdminResponse;
import net.siegeln.cameleer.saas.vendor.VendorAdminService.VendorAdmin;
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();
}
}

View File

@@ -0,0 +1,146 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.account.AccountService;
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 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;
private final PasswordResetNotificationService passwordNotificationService;
public VendorAdminService(LogtoManagementClient logtoClient,
AccountService accountService,
EmailConnectorService emailConnectorService,
PasswordResetNotificationService passwordNotificationService) {
this.logtoClient = logtoClient;
this.accountService = accountService;
this.emailConnectorService = emailConnectorService;
this.passwordNotificationService = passwordNotificationService;
}
// --- 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 only)
// Create user with primaryEmail only; no org assignment
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);
String username = request.email().substring(0, request.email().indexOf('@'));
userId = logtoClient.createUserWithPassword(username, tempPassword, null, null);
logtoClient.updateUserProfile(userId, Map.of("primaryEmail", request.email()));
invited = false;
log.info("Created vendor admin with credentials: {}", request.email());
}
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) {
verifyIsVendorAdmin(userId);
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()) {
passwordNotificationService.sendNotification(email);
}
}
} catch (Exception e) {
log.warn("Failed to send password reset notification: {}", e.getMessage());
}
}
public void resetAdminMfa(String userId) {
verifyIsVendorAdmin(userId);
logtoClient.deleteAllMfaVerifications(userId);
log.info("Reset MFA for vendor admin {}", userId);
}
private void verifyIsVendorAdmin(String userId) {
boolean isAdmin = listAdmins().stream()
.anyMatch(a -> userId.equals(a.userId()));
if (!isAdmin) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User is not a platform administrator");
}
}
}

View File

@@ -1,5 +1,6 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.config.TenantContext;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient;
@@ -46,6 +47,9 @@ class TenantPortalServiceTest {
@Mock
private TenantProvisioner tenantProvisioner;
@Mock
private AccountService accountService;
private final ProvisioningProperties provisioningProps = new ProvisioningProperties(
null, null, null, null, null, "test.example.com", "https", null, null, null, null, null, null, null, null, null);
@@ -56,7 +60,7 @@ class TenantPortalServiceTest {
@BeforeEach
void setUp() {
TenantContext.setTenantId(tenantId);
tenantPortalService = new TenantPortalService(tenantService, licenseService, serverApiClient, logtoClient, tenantProvisioner, provisioningProps, null);
tenantPortalService = new TenantPortalService(tenantService, licenseService, serverApiClient, logtoClient, tenantProvisioner, provisioningProps, null, accountService);
}
@AfterEach

View File

@@ -6,7 +6,7 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
- `main.tsx` — React 19 root
- `router.tsx``/vendor/*` + `/tenant/*` with `RequireScope` guards, `LandingRedirect` that waits for scopes (redirects to `/onboarding` if user has zero orgs), `/register` route for OIDC sign-up flow, `/onboarding` route for self-service tenant creation
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Metrics, Infrastructure, Email Connector, Logto Console), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Metrics, Infrastructure, Email Connector, Administrators, Logto Console), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings. TopBar user dropdown includes "Account Settings" link.
- `OrgResolver.tsx` — merges global + org-scoped token scopes (vendor's platform:admin is global)
- `config.ts` — fetch Logto config from /platform/api/config
@@ -22,8 +22,9 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
## Pages
- **Onboarding**: `OnboardingPage.tsx` — self-service trial tenant creation (org name + slug), shown to users with zero org memberships after sign-up
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`, `EmailConfigPage.tsx` (SMTP connector config, registration toggle, test email)
- **Tenant pages**: `TenantDashboardPage.tsx` (restart + upgrade server), `TenantLicensePage.tsx`, `SsoPage.tsx`, `TeamPage.tsx` (reset member passwords), `TenantAuditPage.tsx`, `SettingsPage.tsx` (change own password, reset server admin password)
- **Shared pages**: `AccountSettingsPage.tsx` `/settings/account`, any authenticated user. Profile, password (with current-password verification), TOTP MFA, passkeys. Composes shared components from `components/account/`.
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`, `EmailConfigPage.tsx` (SMTP connector config, registration toggle, test email), `VendorAdminsPage.tsx` (platform admin list, invite/create, remove, reset password/MFA)
- **Tenant pages**: `TenantDashboardPage.tsx` (restart + upgrade server), `TenantLicensePage.tsx`, `SsoPage.tsx`, `TeamPage.tsx` (reset member passwords), `TenantAuditPage.tsx`, `SettingsPage.tsx` (imports shared account components, plus tenant-specific auth policy, MFA enforcement toggle, server admin password)
## Custom Sign-in UI (`ui/sign-in/`)

100
ui/src/api/account-hooks.ts Normal file
View File

@@ -0,0 +1,100 @@
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 }),
});
}

View File

@@ -1,6 +1,19 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, MfaStatus, MfaSetupResponse, BackupCodesResponse, PasskeyCredential, AuthPolicy } from '../types/api';
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, AuthPolicy } from '../types/api';
// 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';
export function useTenantDashboard() {
return useQuery<DashboardData>({
@@ -121,6 +134,14 @@ export function useResetTeamMemberPassword() {
});
}
export function useResetTeamMemberMfa() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
});
}
export function useTenantSettings() {
return useQuery<TenantSettings>({
queryKey: ['tenant', 'settings'],
@@ -128,6 +149,14 @@ export function useTenantSettings() {
});
}
export function useUpdateTenantSettings() {
const qc = useQueryClient();
return useMutation<void, Error, Record<string, unknown>>({
mutationFn: (updates) => api.patch('/tenant/settings', updates),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }),
});
}
export function useTenantAuditLog(filters: Omit<AuditLogFilters, 'tenantId'>) {
const params = new URLSearchParams();
if (filters.action) params.set('action', filters.action);
@@ -144,90 +173,6 @@ export function useTenantAuditLog(filters: Omit<AuditLogFilters, 'tenantId'>) {
});
}
// MFA hooks
export function useMfaStatus() {
return useQuery<MfaStatus>({
queryKey: ['tenant', 'mfa', 'status'],
queryFn: () => api.get('/tenant/mfa/status'),
});
}
export function useMfaSetup() {
return useMutation<MfaSetupResponse, Error, void>({
mutationFn: () => api.post('/tenant/mfa/totp/setup'),
});
}
export function useMfaVerify() {
const qc = useQueryClient();
return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({
mutationFn: (body) => api.post('/tenant/mfa/totp/verify', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useMfaBackupCodes() {
const qc = useQueryClient();
return useMutation<BackupCodesResponse, Error, void>({
mutationFn: () => api.post('/tenant/mfa/backup-codes'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useMfaRemove() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.delete('/tenant/mfa/totp'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useResetTeamMemberMfa() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
});
}
export function useUpdateTenantSettings() {
const qc = useQueryClient();
return useMutation<void, Error, Record<string, unknown>>({
mutationFn: (updates) => api.patch('/tenant/settings', updates),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }),
});
}
// Passkey hooks
export function usePasskeyList() {
return useQuery<PasskeyCredential[]>({
queryKey: ['tenant', 'mfa', 'webauthn'],
queryFn: () => api.get('/tenant/mfa/webauthn'),
});
}
export function useRenamePasskey() {
const qc = useQueryClient();
return useMutation<void, Error, { id: string; name: string }>({
mutationFn: ({ id, name }) => api.patch(`/tenant/mfa/webauthn/${id}/name`, { name }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useDeletePasskey() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => api.delete(`/tenant/mfa/webauthn/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useUpdateMfaMethodPreference() {
return useMutation<void, Error, string>({
mutationFn: (preference) => api.post('/tenant/mfa/method-preference', { preference }),
});
}
// Auth settings hooks
export function useTenantAuthSettings() {
return useQuery<AuthPolicy>({

View File

@@ -0,0 +1,43 @@
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'] }),
});
}

View File

@@ -4,7 +4,7 @@ import {
Sidebar,
TopBar,
} from '@cameleer/design-system';
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail, BarChart3, Server, ExternalLink, Key, Lock } from 'lucide-react';
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail, BarChart3, Server, ExternalLink, Key, Lock, UserCog } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/useAuth';
import { useScopes } from '../auth/useScopes';
@@ -55,6 +55,14 @@ export function Layout() {
return { label };
});
const userMenuItems = [
{
label: 'Account Settings',
icon: <Settings size={14} />,
onClick: () => navigate('/settings/account'),
},
];
const sidebar = (
<Sidebar collapsed={false} onCollapseToggle={() => {}}>
<Sidebar.Header
@@ -157,6 +165,15 @@ export function Layout() {
<Lock size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
Auth Policy
</div>
<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')}
>
<UserCog size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
Administrators
</div>
<div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
@@ -240,6 +257,7 @@ export function Layout() {
<TopBar
breadcrumb={breadcrumb}
user={username ? { name: username } : undefined}
userMenuItems={userMenuItems}
onLogout={logout}
/>
<Outlet />

View File

@@ -0,0 +1,259 @@
import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { errorMessage } from '../../api/client';
import {
Alert,
Badge,
Button,
Card,
FormField,
Input,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
useAccountMfaStatus,
useAccountMfaSetup,
useAccountMfaVerify,
useAccountBackupCodes,
useAccountMfaRemove,
} from '../../api/account-hooks';
import styles from '../../styles/platform.module.css';
export function MfaSection() {
const { toast } = useToast();
const { data: mfaStatus, isLoading: statusLoading } = 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);
async function handleStartSetup() {
try {
const data = await setup.mutateAsync();
setSetupData(data);
setVerifyCode('');
} catch (err) {
toast({ title: 'Failed to start MFA setup', description: errorMessage(err), variant: 'error' });
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault();
if (!setupData) return;
try {
const result = await verify.mutateAsync({ secret: setupData.secret, code: verifyCode });
if (result.verified) {
const bc = await backupCodes.mutateAsync();
setCodes(bc.codes);
setSetupData(null);
setVerifyCode('');
setCodesSaved(false);
toast({ title: 'MFA enabled successfully', variant: 'success' });
} else {
toast({ title: 'Invalid code. Please try again.', variant: 'error' });
}
} catch (err) {
toast({ title: 'Verification failed', description: errorMessage(err), variant: 'error' });
}
}
async function handleRegenerateCodes() {
try {
const bc = await backupCodes.mutateAsync();
setCodes(bc.codes);
setCodesSaved(false);
toast({ title: 'Backup codes regenerated', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to regenerate backup codes', description: errorMessage(err), variant: 'error' });
}
}
async function handleRemove() {
try {
await remove.mutateAsync();
setConfirmRemove(false);
setCodes(null);
setSetupData(null);
toast({ title: 'MFA removed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to remove MFA', description: errorMessage(err), variant: 'error' });
}
}
function handleCopyAll() {
if (!codes) return;
navigator.clipboard.writeText(codes.join('\n'));
toast({ title: 'Backup codes copied to clipboard', variant: 'success' });
}
function handleDownload() {
if (!codes) return;
const blob = new Blob([codes.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'cameleer-mfa-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
}
if (statusLoading) {
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
</Card>
);
}
// Backup codes display
if (codes) {
return (
<Card title="Multi-Factor Authentication">
<Alert variant="warning" title="Save your backup codes">
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
</Alert>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={() => setCodes(null)}>
Done
</Button>
</div>
</Card>
);
}
// Setup flow — QR code + verification
if (setupData) {
return (
<Card title="Multi-Factor Authentication">
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<QRCodeSVG value={setupData.secretQrCode} size={200} />
</div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={() => { setSetupData(null); setVerifyCode(''); }}>
Cancel
</Button>
</div>
</form>
</Card>
);
}
// Main view — enrolled or not
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? (
<Badge label="Enrolled" color="success" />
) : (
<Badge label="Not enrolled" color="auto" />
)}
</div>
{mfaStatus?.enrolled ? (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Your account is protected with a TOTP authenticator app.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
Regenerate backup codes
</Button>
{confirmRemove ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Alert variant="error" title="This will disable MFA on your account." />
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
Confirm removal
</Button>
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
Remove MFA
</Button>
)}
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</Card>
);
}

View File

@@ -0,0 +1,136 @@
import { useState } from 'react';
import { errorMessage } from '../../api/client';
import {
Alert,
Button,
Card,
Input,
useToast,
} from '@cameleer/design-system';
import {
useAccountMfaStatus,
useAccountPasskeyList,
useAccountRenamePasskey,
useAccountDeletePasskey,
} from '../../api/account-hooks';
import styles from '../../styles/platform.module.css';
export function PasskeyNudgeBanner() {
const { data: status } = useAccountMfaStatus();
const [dismissed, setDismissed] = useState(false);
const lastDismissed = localStorage.getItem('passkey_nudge_dismissed');
const recentlyDismissed = lastDismissed && (Date.now() - Number(lastDismissed)) < 30 * 24 * 60 * 60 * 1000;
if (dismissed || recentlyDismissed || !status || status.passkeyEnrolled) return null;
function handleDismiss() {
localStorage.setItem('passkey_nudge_dismissed', String(Date.now()));
setDismissed(true);
}
return (
<Alert variant="info" title="Sign in faster with a passkey">
<p style={{ margin: '4px 0 12px' }}>
Use your fingerprint, face, or security key instead of typing a code every time.
</p>
<Button size="sm" variant="secondary" onClick={handleDismiss}>Not now</Button>
</Alert>
);
}
export function PasskeySection() {
const { toast } = useToast();
const { data: passkeys, isLoading } = useAccountPasskeyList();
const renamePasskey = useAccountRenamePasskey();
const deletePasskey = useAccountDeletePasskey();
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
function parseAgent(agent: string | null): string {
if (!agent) return 'Unknown device';
if (agent.includes('Chrome')) return agent.includes('Windows') ? 'Chrome on Windows' : agent.includes('Mac') ? 'Chrome on macOS' : agent.includes('Android') ? 'Chrome on Android' : 'Chrome';
if (agent.includes('Safari') && !agent.includes('Chrome')) return agent.includes('iPhone') ? 'Safari on iPhone' : 'Safari on macOS';
if (agent.includes('Firefox')) return 'Firefox';
if (agent.includes('Edge')) return 'Edge';
return 'Browser';
}
function startRename(id: string, currentName: string | null) {
setEditingId(id);
setEditName(currentName ?? '');
}
async function handleRename(id: string) {
try {
await renamePasskey.mutateAsync({ id, name: editName });
setEditingId(null);
toast({ title: 'Passkey renamed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to rename passkey', description: errorMessage(err), variant: 'error' });
}
}
async function handleDelete(id: string) {
try {
await deletePasskey.mutateAsync(id);
setConfirmDeleteId(null);
toast({ title: 'Passkey removed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to remove passkey', description: errorMessage(err), variant: 'error' });
}
}
if (isLoading) return null;
const credentials = passkeys ?? [];
return (
<Card title="Passkeys">
<p className={styles.description} style={{ marginTop: 0 }}>
Use your fingerprint, face, or security key to sign in faster.
</p>
{credentials.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
No passkeys registered. Passkeys can be registered during sign-in when prompted.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{credentials.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)} placeholder="Passkey name" style={{ maxWidth: 200 }} />
<Button size="sm" variant="primary" onClick={() => handleRename(pk.id)} loading={renamePasskey.isPending}>Save</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>Cancel</Button>
</div>
) : (
<>
<div style={{ fontWeight: 500 }}>{pk.name || 'Unnamed passkey'}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{parseAgent(pk.agent)} &middot; Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
</div>
</>
)}
</div>
{editingId !== pk.id && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" variant="secondary" onClick={() => startRename(pk.id, pk.name)}>Rename</Button>
{confirmDeleteId === pk.id ? (
<>
<Button size="sm" variant="danger" onClick={() => handleDelete(pk.id)} loading={deletePasskey.isPending}>Confirm</Button>
<Button size="sm" variant="secondary" onClick={() => setConfirmDeleteId(null)}>Cancel</Button>
</>
) : (
<Button size="sm" variant="danger" onClick={() => setConfirmDeleteId(pk.id)}>Remove</Button>
)}
</div>
)}
</div>
))}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import { errorMessage } from '../../api/client';
import {
Button,
Card,
FormField,
Input,
useToast,
} from '@cameleer/design-system';
import { useChangePassword } from '../../api/account-hooks';
import styles from '../../styles/platform.module.css';
export function PasswordChangeSection() {
const { toast } = useToast();
const changePassword = useChangePassword();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
if (newPassword.length < 8) {
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
return;
}
if (newPassword !== confirmPassword) {
toast({ title: 'Passwords do not match', variant: 'error' });
return;
}
try {
await changePassword.mutateAsync({ currentPassword, newPassword });
toast({ title: 'Password changed successfully', variant: 'success' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err) {
toast({ title: 'Failed to change password', description: errorMessage(err), variant: 'error' });
}
}
return (
<Card title="Change Password">
<p className={styles.description} style={{ marginTop: 0 }}>
Update your login password. Minimum 8 characters.
</p>
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
<FormField label="Current password" htmlFor="current-pw">
<Input
id="current-pw"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
required
/>
</FormField>
<FormField label="New password" htmlFor="new-pw">
<Input
id="new-pw"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
required
minLength={8}
/>
</FormField>
<FormField label="Confirm password" htmlFor="confirm-pw">
<Input
id="confirm-pw"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
minLength={8}
/>
</FormField>
<div>
<Button type="submit" variant="primary" loading={changePassword.isPending}>
Change Password
</Button>
</div>
</form>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
import { useState, useEffect } from 'react';
import { errorMessage } from '../../api/client';
import {
Button,
Card,
FormField,
Input,
Spinner,
useToast,
} from '@cameleer/design-system';
import { useAccountProfile, useUpdateDisplayName } from '../../api/account-hooks';
export function ProfileSection() {
const { toast } = useToast();
const { data: profile, isLoading } = useAccountProfile();
const updateDisplayName = useUpdateDisplayName();
const [name, setName] = useState('');
useEffect(() => {
if (profile) {
setName(profile.name ?? '');
}
}, [profile]);
const isDirty = profile ? name !== (profile.name ?? '') : false;
async function handleSave(e: React.FormEvent) {
e.preventDefault();
try {
await updateDisplayName.mutateAsync(name);
toast({ title: 'Display name updated', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update display name', description: errorMessage(err), variant: 'error' });
}
}
if (isLoading) {
return (
<Card title="Profile">
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
</Card>
);
}
return (
<Card title="Profile">
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Email" htmlFor="profile-email">
<Input
id="profile-email"
type="email"
value={profile?.email ?? ''}
readOnly
disabled
/>
</FormField>
<FormField label="Display name" htmlFor="profile-name">
<Input
id="profile-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter display name"
/>
</FormField>
<div>
<Button
type="submit"
variant="primary"
loading={updateDisplayName.isPending}
disabled={!isDirty}
>
Save
</Button>
</div>
</form>
</Card>
);
}

View File

@@ -0,0 +1,27 @@
import { useNavigate } from 'react-router';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@cameleer/design-system';
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={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button variant="ghost" onClick={() => navigate(-1)} style={{ padding: '4px 8px' }}>
<ArrowLeft size={16} />
</Button>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Account Settings</h1>
</div>
<ProfileSection />
<PasswordChangeSection />
<MfaSection />
<PasskeyNudgeBanner />
<PasskeySection />
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { errorMessage } from '../../api/client';
import {
Alert,
@@ -12,11 +11,14 @@ import {
useToast,
} from '@cameleer/design-system';
import {
useTenantSettings, useChangeOwnPassword, useResetServerAdminPassword,
useMfaStatus, useMfaSetup, useMfaVerify, useMfaBackupCodes, useMfaRemove,
useUpdateTenantSettings, usePasskeyList, useRenamePasskey, useDeletePasskey,
useTenantSettings,
useResetServerAdminPassword,
useUpdateTenantSettings,
useTenantAuthSettings, useUpdateTenantAuthSettings,
} from '../../api/tenant-hooks';
import { MfaSection } from '../../components/account/MfaSection';
import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection';
import { PasswordChangeSection } from '../../components/account/PasswordChangeSection';
import { useScopes } from '../../auth/useScopes';
import { tierColor } from '../../utils/tier';
import styles from '../../styles/platform.module.css';
@@ -31,244 +33,6 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
}
}
function MfaSection() {
const { toast } = useToast();
const { data: mfaStatus, isLoading: statusLoading } = useMfaStatus();
const setup = useMfaSetup();
const verify = useMfaVerify();
const backupCodes = useMfaBackupCodes();
const remove = useMfaRemove();
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);
async function handleStartSetup() {
try {
const data = await setup.mutateAsync();
setSetupData(data);
setVerifyCode('');
} catch (err) {
toast({ title: 'Failed to start MFA setup', description: errorMessage(err), variant: 'error' });
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault();
if (!setupData) return;
try {
const result = await verify.mutateAsync({ secret: setupData.secret, code: verifyCode });
if (result.verified) {
const bc = await backupCodes.mutateAsync();
setCodes(bc.codes);
setSetupData(null);
setVerifyCode('');
setCodesSaved(false);
toast({ title: 'MFA enabled successfully', variant: 'success' });
} else {
toast({ title: 'Invalid code. Please try again.', variant: 'error' });
}
} catch (err) {
toast({ title: 'Verification failed', description: errorMessage(err), variant: 'error' });
}
}
async function handleRegenerateCodes() {
try {
const bc = await backupCodes.mutateAsync();
setCodes(bc.codes);
setCodesSaved(false);
toast({ title: 'Backup codes regenerated', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to regenerate backup codes', description: errorMessage(err), variant: 'error' });
}
}
async function handleRemove() {
try {
await remove.mutateAsync();
setConfirmRemove(false);
setCodes(null);
setSetupData(null);
toast({ title: 'MFA removed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to remove MFA', description: errorMessage(err), variant: 'error' });
}
}
function handleCopyAll() {
if (!codes) return;
navigator.clipboard.writeText(codes.join('\n'));
toast({ title: 'Backup codes copied to clipboard', variant: 'success' });
}
function handleDownload() {
if (!codes) return;
const blob = new Blob([codes.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'cameleer-mfa-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
}
if (statusLoading) {
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
</Card>
);
}
// Backup codes display
if (codes) {
return (
<Card title="Multi-Factor Authentication">
<Alert variant="warning" title="Save your backup codes">
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
</Alert>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={() => setCodes(null)}>
Done
</Button>
</div>
</Card>
);
}
// Setup flow — QR code + verification
if (setupData) {
return (
<Card title="Multi-Factor Authentication">
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<QRCodeSVG value={setupData.secretQrCode} size={200} />
</div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={() => { setSetupData(null); setVerifyCode(''); }}>
Cancel
</Button>
</div>
</form>
</Card>
);
}
// Main view — enrolled or not
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? (
<Badge label="Enrolled" color="success" />
) : (
<Badge label="Not enrolled" color="auto" />
)}
</div>
{mfaStatus?.enrolled ? (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Your account is protected with a TOTP authenticator app.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
Regenerate backup codes
</Button>
{confirmRemove ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Alert variant="error" title="This will disable MFA on your account." />
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
Confirm removal
</Button>
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
Remove MFA
</Button>
)}
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</Card>
);
}
function MfaEnforcementToggle() {
const scopes = useScopes();
const { toast } = useToast();
@@ -341,126 +105,6 @@ function MfaEnforcementToggle() {
);
}
function PasskeyNudgeBanner() {
const { data: status } = useMfaStatus();
const [dismissed, setDismissed] = useState(false);
const lastDismissed = localStorage.getItem('passkey_nudge_dismissed');
const recentlyDismissed = lastDismissed && (Date.now() - Number(lastDismissed)) < 30 * 24 * 60 * 60 * 1000;
if (dismissed || recentlyDismissed || !status || status.passkeyEnrolled) return null;
function handleDismiss() {
localStorage.setItem('passkey_nudge_dismissed', String(Date.now()));
setDismissed(true);
}
return (
<Alert variant="info" title="Sign in faster with a passkey">
<p style={{ margin: '4px 0 12px' }}>
Use your fingerprint, face, or security key instead of typing a code every time.
</p>
<Button size="sm" variant="secondary" onClick={handleDismiss}>Not now</Button>
</Alert>
);
}
function PasskeySection() {
const { toast } = useToast();
const { data: passkeys, isLoading } = usePasskeyList();
const renamePasskey = useRenamePasskey();
const deletePasskey = useDeletePasskey();
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
function parseAgent(agent: string | null): string {
if (!agent) return 'Unknown device';
if (agent.includes('Chrome')) return agent.includes('Windows') ? 'Chrome on Windows' : agent.includes('Mac') ? 'Chrome on macOS' : agent.includes('Android') ? 'Chrome on Android' : 'Chrome';
if (agent.includes('Safari') && !agent.includes('Chrome')) return agent.includes('iPhone') ? 'Safari on iPhone' : 'Safari on macOS';
if (agent.includes('Firefox')) return 'Firefox';
if (agent.includes('Edge')) return 'Edge';
return 'Browser';
}
function startRename(id: string, currentName: string | null) {
setEditingId(id);
setEditName(currentName ?? '');
}
async function handleRename(id: string) {
try {
await renamePasskey.mutateAsync({ id, name: editName });
setEditingId(null);
toast({ title: 'Passkey renamed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to rename passkey', description: errorMessage(err), variant: 'error' });
}
}
async function handleDelete(id: string) {
try {
await deletePasskey.mutateAsync(id);
setConfirmDeleteId(null);
toast({ title: 'Passkey removed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to remove passkey', description: errorMessage(err), variant: 'error' });
}
}
if (isLoading) return null;
const credentials = passkeys ?? [];
return (
<Card title="Passkeys">
<p className={styles.description} style={{ marginTop: 0 }}>
Use your fingerprint, face, or security key to sign in faster.
</p>
{credentials.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
No passkeys registered. Passkeys can be registered during sign-in when prompted.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{credentials.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)} placeholder="Passkey name" style={{ maxWidth: 200 }} />
<Button size="sm" variant="primary" onClick={() => handleRename(pk.id)} loading={renamePasskey.isPending}>Save</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>Cancel</Button>
</div>
) : (
<>
<div style={{ fontWeight: 500 }}>{pk.name || 'Unnamed passkey'}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{parseAgent(pk.agent)} &middot; Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
</div>
</>
)}
</div>
{editingId !== pk.id && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" variant="secondary" onClick={() => startRename(pk.id, pk.name)}>Rename</Button>
{confirmDeleteId === pk.id ? (
<>
<Button size="sm" variant="danger" onClick={() => handleDelete(pk.id)} loading={deletePasskey.isPending}>Confirm</Button>
<Button size="sm" variant="secondary" onClick={() => setConfirmDeleteId(null)}>Cancel</Button>
</>
) : (
<Button size="sm" variant="danger" onClick={() => setConfirmDeleteId(pk.id)}>Remove</Button>
)}
</div>
)}
</div>
))}
</div>
)}
</Card>
);
}
function AuthPolicySection() {
const scopes = useScopes();
const { toast } = useToast();
@@ -544,34 +188,11 @@ function AuthPolicySection() {
export function SettingsPage() {
const { data, isLoading, isError } = useTenantSettings();
const changePassword = useChangeOwnPassword();
const resetServerAdmin = useResetServerAdminPassword();
const { toast } = useToast();
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [serverAdminPw, setServerAdminPw] = useState('');
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
if (newPassword.length < 8) {
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
return;
}
if (newPassword !== confirmPassword) {
toast({ title: 'Passwords do not match', variant: 'error' });
return;
}
try {
await changePassword.mutateAsync(newPassword);
toast({ title: 'Password changed successfully', variant: 'success' });
setNewPassword('');
setConfirmPassword('');
} catch (err) {
toast({ title: 'Failed to change password', description: errorMessage(err), variant: 'error' });
}
}
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
@@ -628,40 +249,7 @@ export function SettingsPage() {
</p>
</Card>
<Card title="Change Password">
<p className={styles.description} style={{ marginTop: 0 }}>
Update your login password. Minimum 8 characters.
</p>
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
<FormField label="New password" htmlFor="new-pw">
<Input
id="new-pw"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
required
minLength={8}
/>
</FormField>
<FormField label="Confirm password" htmlFor="confirm-pw">
<Input
id="confirm-pw"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
minLength={8}
/>
</FormField>
<div>
<Button type="submit" variant="primary" loading={changePassword.isPending}>
Change Password
</Button>
</div>
</form>
</Card>
<PasswordChangeSection />
<Card title="Server Admin Password">
<p className={styles.description} style={{ marginTop: 0 }}>

399
ui/src/pages/vendor/VendorAdminsPage.tsx vendored Normal file
View File

@@ -0,0 +1,399 @@
import { useState } from 'react';
import {
Alert,
AlertDialog,
Badge,
Button,
Card,
DataTable,
EmptyState,
FormField,
Input,
Spinner,
useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { Copy, Plus, ShieldCheck } from 'lucide-react';
import { errorMessage } from '../../api/client';
import {
useVendorAdminList,
useCreateVendorAdmin,
useRemoveVendorAdmin,
useResetVendorAdminPassword,
useResetVendorAdminMfa,
} from '../../api/vendor-admin-hooks';
import { useEmailConnector } from '../../api/email-connector-hooks';
import { useMe } from '../../api/hooks';
// DataTable requires T extends { id: string }
interface AdminRow {
id: string;
name: string;
email: string;
}
function toRow(raw: { userId: string; name: string; email: string }): AdminRow {
return { id: raw.userId, name: raw.name || raw.email, email: raw.email };
}
export function VendorAdminsPage() {
const { data: rawAdmins, isLoading, isError } = useVendorAdminList();
const { data: me } = useMe();
const { data: emailConnector, isLoading: emailLoading } = useEmailConnector();
const createAdmin = useCreateVendorAdmin();
const removeAdmin = useRemoveVendorAdmin();
const resetPassword = useResetVendorAdminPassword();
const resetMfa = useResetVendorAdminMfa();
const { toast } = useToast();
const currentUserId = me?.userId ?? null;
// Add admin dialog state
const [showAdd, setShowAdd] = useState(false);
const [addEmail, setAddEmail] = useState('');
const [addTempPassword, setAddTempPassword] = useState('');
const [createdCredentials, setCreatedCredentials] = useState<{ email: string; tempPassword: string } | null>(null);
// Remove dialog state
const [removeTarget, setRemoveTarget] = useState<AdminRow | null>(null);
// Reset password state
const [pwTarget, setPwTarget] = useState<AdminRow | null>(null);
const [pwValue, setPwValue] = useState('');
// Reset MFA state
const [mfaTarget, setMfaTarget] = useState<AdminRow | null>(null);
const admins: AdminRow[] = (rawAdmins ?? []).map(toRow);
const emailConfigured = !emailLoading && emailConnector != null;
function openAdd() {
setAddEmail('');
setAddTempPassword('');
setCreatedCredentials(null);
setShowAdd(true);
}
function closeAdd() {
setShowAdd(false);
setAddEmail('');
setAddTempPassword('');
setCreatedCredentials(null);
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!addEmail.trim()) return;
try {
const result = await createAdmin.mutateAsync({
email: addEmail.trim(),
tempPassword: emailConfigured ? undefined : addTempPassword || undefined,
});
if (result.invited) {
toast({ title: `Invitation sent to ${addEmail}`, variant: 'success' });
closeAdd();
} else if (result.tempPassword) {
setCreatedCredentials({ email: addEmail.trim(), tempPassword: result.tempPassword });
} else {
toast({ title: `Administrator ${addEmail} created`, variant: 'success' });
closeAdd();
}
} catch (err) {
toast({ title: 'Failed to add administrator', description: errorMessage(err), variant: 'error' });
}
}
async function handleRemove() {
if (!removeTarget) return;
try {
await removeAdmin.mutateAsync(removeTarget.id);
toast({ title: `Removed ${removeTarget.name}`, variant: 'success' });
setRemoveTarget(null);
} catch (err) {
toast({ title: 'Remove failed', description: errorMessage(err), variant: 'error' });
setRemoveTarget(null);
}
}
const columns: Column<AdminRow>[] = [
{
key: 'name',
header: 'Name',
render: (_v, row) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{row.name}
{row.id === currentUserId && (
<Badge label="You" color="primary" />
)}
</span>
),
},
{
key: 'email',
header: 'Email',
render: (_v, row) => (
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>{row.email}</span>
),
},
{
key: 'id',
header: 'Actions',
render: (_v, row) => (
<div style={{ display: 'flex', gap: 6 }}>
<Button
variant="secondary"
size="sm"
onClick={(e) => { e.stopPropagation(); setPwTarget(row); setPwValue(''); }}
>
Reset Password
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => { e.stopPropagation(); setMfaTarget(row); }}
>
Reset MFA
</Button>
<Button
variant="danger"
size="sm"
disabled={row.id === currentUserId}
onClick={(e) => { e.stopPropagation(); setRemoveTarget(row); }}
>
Remove
</Button>
</div>
),
},
];
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spinner />
</div>
);
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Alert variant="error" title="Failed to load administrators">
Could not fetch administrator list. Please refresh.
</Alert>
</div>
);
}
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Platform Administrators</h1>
<Button variant="primary" onClick={openAdd}>
<Plus size={16} style={{ marginRight: 6, verticalAlign: -3 }} />
Add Administrator
</Button>
</div>
{/* Add admin form */}
{showAdd && !createdCredentials && (
<Card title="Add Platform Administrator">
{emailLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
) : (
<form onSubmit={handleAdd} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{emailConfigured ? (
<Alert variant="info">
An invitation email will be sent to the new administrator.
</Alert>
) : (
<Alert variant="warning">
Email connector is not configured. The new administrator will receive a temporary
password instead of an invitation link.
</Alert>
)}
<FormField label="Email address" htmlFor="add-admin-email">
<Input
id="add-admin-email"
type="email"
placeholder="admin@example.com"
value={addEmail}
onChange={(e) => setAddEmail(e.target.value)}
required
/>
</FormField>
{!emailConfigured && (
<FormField label="Temporary password" htmlFor="add-admin-pw">
<Input
id="add-admin-pw"
type="password"
placeholder="Leave blank to auto-generate"
value={addTempPassword}
onChange={(e) => setAddTempPassword(e.target.value)}
/>
</FormField>
)}
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={createAdmin.isPending}>
{emailConfigured ? 'Send Invite' : 'Create'}
</Button>
<Button type="button" variant="secondary" onClick={closeAdd}>
Cancel
</Button>
</div>
</form>
)}
</Card>
)}
{/* Credential display after creation (no email connector) */}
{showAdd && createdCredentials && (
<Card title="Administrator Created">
<Alert variant="success">
Account created. Share these credentials securely the temporary password will not be
shown again.
</Alert>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}>Email</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>{createdCredentials.email}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}>Temporary password</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
{createdCredentials.tempPassword}
</span>
<Button
variant="secondary"
size="sm"
onClick={() => {
navigator.clipboard.writeText(createdCredentials.tempPassword).then(() =>
toast({ title: 'Copied to clipboard', variant: 'success' })
);
}}
>
<Copy size={14} style={{ marginRight: 4, verticalAlign: -2 }} />
Copy
</Button>
</div>
</div>
</div>
<div style={{ marginTop: 16 }}>
<Button variant="primary" onClick={closeAdd}>
Done
</Button>
</div>
</Card>
)}
{/* Admin table */}
{admins.length === 0 ? (
<EmptyState
icon={<ShieldCheck size={32} />}
title="No platform administrators"
description="Add an administrator to grant platform:admin access."
action={
<Button variant="primary" onClick={openAdd}>
<Plus size={16} style={{ marginRight: 6, verticalAlign: -3 }} />
Add Administrator
</Button>
}
/>
) : (
<DataTable columns={columns} data={admins} />
)}
{/* Remove confirmation dialog */}
<AlertDialog
open={removeTarget !== null}
onClose={() => setRemoveTarget(null)}
onConfirm={handleRemove}
title="Remove Administrator"
description={`Remove "${removeTarget?.name ?? ''}" from platform administrators? They will lose platform:admin access immediately.`}
confirmLabel="Remove"
cancelLabel="Cancel"
variant="danger"
loading={removeAdmin.isPending}
/>
{/* Reset MFA inline card */}
{mfaTarget && (
<Card title={`Reset MFA — ${mfaTarget.name || mfaTarget.email}`}>
<Alert variant="warning">
This will remove all MFA factors for this administrator. They will need to re-enroll if
MFA is required.
</Alert>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<Button
variant="danger"
loading={resetMfa.isPending}
onClick={async () => {
try {
await resetMfa.mutateAsync(mfaTarget.id);
toast({ title: `MFA reset for ${mfaTarget.name || mfaTarget.email}`, variant: 'success' });
setMfaTarget(null);
} catch (err) {
toast({ title: 'Failed to reset MFA', description: errorMessage(err), variant: 'error' });
}
}}
>
Reset MFA
</Button>
<Button variant="secondary" onClick={() => setMfaTarget(null)}>Cancel</Button>
</div>
</Card>
)}
{/* Reset password inline card */}
{pwTarget && (
<Card title={`Reset Password — ${pwTarget.name}`}>
<form
onSubmit={async (e) => {
e.preventDefault();
if (pwValue.length < 8) {
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
return;
}
try {
await resetPassword.mutateAsync({ userId: pwTarget.id, password: pwValue });
toast({ title: `Password reset for ${pwTarget.name}`, variant: 'success' });
setPwTarget(null);
setPwValue('');
} catch (err) {
toast({ title: 'Reset failed', description: errorMessage(err), variant: 'error' });
}
}}
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}
>
<FormField label="New password" htmlFor="admin-reset-pw">
<Input
id="admin-reset-pw"
type="password"
placeholder="Min. 8 characters"
value={pwValue}
onChange={(e) => setPwValue(e.target.value)}
required
minLength={8}
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={resetPassword.isPending}>
Reset Password
</Button>
<Button type="button" variant="secondary" onClick={() => { setPwTarget(null); setPwValue(''); }}>
Cancel
</Button>
</div>
</form>
</Card>
)}
</div>
);
}

View File

@@ -19,6 +19,7 @@ import { VendorMetricsPage } from './pages/vendor/VendorMetricsPage';
import { EmailConfigPage } from './pages/vendor/EmailConfigPage';
import { LicenseVerifyPage } from './pages/vendor/LicenseVerifyPage';
import { AuthPolicyPage } from './pages/vendor/AuthPolicyPage';
import { VendorAdminsPage } from './pages/vendor/VendorAdminsPage';
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
import { SsoPage } from './pages/tenant/SsoPage';
@@ -26,6 +27,7 @@ import { TeamPage } from './pages/tenant/TeamPage';
import { SettingsPage } from './pages/tenant/SettingsPage';
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
import { OnboardingPage } from './pages/OnboardingPage';
import { AccountSettingsPage } from './pages/AccountSettingsPage';
function LandingRedirect() {
const scopes = useScopes();
@@ -120,6 +122,11 @@ export function AppRouter() {
<AuthPolicyPage />
</RequireScope>
} />
<Route path="/vendor/admins" element={
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
<VendorAdminsPage />
</RequireScope>
} />
{/* Tenant portal */}
<Route path="/tenant" element={<TenantDashboardPage />} />
@@ -129,6 +136,9 @@ export function AppRouter() {
<Route path="/tenant/audit" element={<TenantAuditPage />} />
<Route path="/tenant/settings" element={<SettingsPage />} />
{/* Account settings — accessible to any authenticated user */}
<Route path="settings/account" element={<AccountSettingsPage />} />
{/* Default redirect — vendor goes to /vendor/tenants, customer to /tenant */}
<Route index element={<LandingRedirect />} />
</Route>

View File

@@ -273,3 +273,27 @@ export interface AuthPolicy {
passkeyEnabled: boolean;
passkeyMode: string;
}
// 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;
}