From 86d9ba49855cb30a1e093c04f84544d4d530cf87 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:20:49 +0200 Subject: [PATCH 01/19] docs: vendor admin management and account settings design spec Two features: multi-vendor admin management (invite/create, remove, reset password/MFA) and shared account settings page (profile, password change with current-password verification, MFA self-service). Includes consolidation plan extracting user-level identity operations from TenantPortalService into new AccountService. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...27-vendor-admin-account-settings-design.md | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md diff --git a/docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md b/docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md new file mode 100644 index 0000000..9cae8d6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md @@ -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 | From 316e5ef6c142a264a4ddb578c1f754681a6f9ecd Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:32:46 +0200 Subject: [PATCH 02/19] docs: implementation plan for vendor admin management and account settings 16 tasks covering: LogtoManagementClient additions, AccountService extraction, AccountController, VendorAdminService/Controller, SecurityConfig updates, frontend component extraction, shared AccountSettingsPage, VendorAdminsPage, and Layout user menu. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...026-04-27-vendor-admin-account-settings.md | 2368 +++++++++++++++++ 1 file changed, 2368 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md diff --git a/docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md b/docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md new file mode 100644 index 0000000..34ac822 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md @@ -0,0 +1,2368 @@ +# Vendor Admin Management & Account Settings — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add multi-vendor admin management and a shared account settings page (profile, password with current-password verification, MFA self-service) accessible to any authenticated user. + +**Architecture:** New `account/` package extracts user-level identity operations from `TenantPortalService` into `AccountService`. Vendor admin CRUD uses a new `VendorAdminService` in the existing `vendor/` package. Frontend extracts MFA/passkey/password components from `SettingsPage.tsx` into shared components, composes them in a new `AccountSettingsPage` at `/settings/account`. TopBar's existing `userMenuItems` prop wires up the user dropdown. + +**Tech Stack:** Java 21 / Spring Boot 3, React 19, @tanstack/react-query, @cameleer/design-system, Logto Management API + +**Spec:** `docs/superpowers/specs/2026-04-27-vendor-admin-account-settings-design.md` + +--- + +## File Structure + +### New Files + +| File | Responsibility | +|------|---------------| +| `src/.../account/AccountService.java` | User-level identity operations: profile, password (with verification), MFA, passkeys | +| `src/.../account/AccountController.java` | `/api/account/*` REST endpoints — any authenticated user | +| `src/.../vendor/VendorAdminService.java` | Vendor admin CRUD: list, create/invite, remove, reset password/MFA | +| `src/.../vendor/VendorAdminController.java` | `/api/vendor/admins/*` REST endpoints — platform:admin only | +| `ui/src/api/account-hooks.ts` | React Query hooks for `/api/account/*` | +| `ui/src/api/vendor-admin-hooks.ts` | React Query hooks for `/api/vendor/admins/*` | +| `ui/src/components/account/ProfileSection.tsx` | Display name + email form | +| `ui/src/components/account/PasswordChangeSection.tsx` | Current + new password form | +| `ui/src/components/account/MfaSection.tsx` | TOTP setup/remove/backup codes | +| `ui/src/components/account/PasskeySection.tsx` | Passkey list/rename/delete + nudge banner | +| `ui/src/pages/AccountSettingsPage.tsx` | Shared account settings — composes all four sections | +| `ui/src/pages/vendor/VendorAdminsPage.tsx` | Vendor admin list + add/remove/reset actions | + +### Modified Files + +| File | Change | +|------|--------| +| `src/.../identity/LogtoManagementClient.java` | Add `verifyUserPassword`, `listRoleUsers`, `assignGlobalRole`, `revokeGlobalRole`, `getRoleByName` | +| `src/.../config/SecurityConfig.java` | Add `/api/account/**` as `authenticated()` | +| `src/.../config/MfaEnforcementFilter.java` | Add `/api/account/mfa/` to exempt prefixes | +| `src/.../portal/TenantPortalService.java` | Delegate MFA/password/passkey methods to `AccountService` | +| `src/.../onboarding/OnboardingService.java` | Use `AccountService.updateDisplayName()` | +| `ui/src/types/api.ts` | Add `AccountProfile`, `VendorAdmin`, `CreateAdminRequest`, `CreateAdminResponse` types | +| `ui/src/api/tenant-hooks.ts` | Replace MFA/password hook implementations with re-exports from `account-hooks.ts` | +| `ui/src/pages/tenant/SettingsPage.tsx` | Import shared components from `components/account/` | +| `ui/src/components/Layout.tsx` | Add `userMenuItems` prop to TopBar with "Account Settings" item | +| `ui/src/router.tsx` | Add `/settings/account` and `/vendor/admins` routes | + +All Java paths below are relative to `src/main/java/net/siegeln/cameleer/saas/`. + +--- + +## Task 1: LogtoManagementClient — New Methods + +**Files:** +- Modify: `identity/LogtoManagementClient.java` + +These five new methods follow the exact same pattern as existing methods in this file: get an access token, make an HTTP call with `RestClient`, parse the JSON response. + +- [ ] **Step 1: Add `verifyUserPassword` method** + +Add after the existing `updateUserPassword` method (after line ~527): + +```java +/** + * Verify a user's current password via Management API. + * Returns true if password is correct, false otherwise. + */ +public boolean verifyUserPassword(String userId, String password) { + try { + var token = getAccessToken(); + restClient.post() + .uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/password/verify") + .header("Authorization", "Bearer " + token) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(Map.of("password", password)) + .retrieve() + .toBodilessEntity(); + return true; + } catch (org.springframework.web.client.HttpClientErrorException e) { + if (e.getStatusCode().value() == 422 || e.getStatusCode().value() == 400) { + return false; + } + throw e; + } +} +``` + +- [ ] **Step 2: Add role management methods** + +Add after the existing `getUser` method (after line ~674): + +```java +/** + * List all users assigned to a global role. + */ +@SuppressWarnings("unchecked") +public List> listRoleUsers(String roleId) { + var token = getAccessToken(); + var response = restClient.get() + .uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users?page=1&page_size=200") + .header("Authorization", "Bearer " + token) + .retrieve() + .body(List.class); + return response != null ? response : List.of(); +} + +/** + * Find a global role by its exact name. Returns the role map or null. + */ +@SuppressWarnings("unchecked") +public Map getRoleByName(String roleName) { + var token = getAccessToken(); + var response = restClient.get() + .uri(config.getLogtoEndpoint() + "/api/roles?search=" + + java.net.URLEncoder.encode(roleName, java.nio.charset.StandardCharsets.UTF_8) + + "&page=1&page_size=20") + .header("Authorization", "Bearer " + token) + .retrieve() + .body(List.class); + if (response == null) return null; + return ((List>) response).stream() + .filter(r -> roleName.equals(r.get("name"))) + .findFirst() + .orElse(null); +} + +/** + * Assign a global role to a user. + */ +public void assignGlobalRole(String userId, String roleId) { + var token = getAccessToken(); + restClient.post() + .uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users") + .header("Authorization", "Bearer " + token) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(Map.of("userIds", List.of(userId))) + .retrieve() + .toBodilessEntity(); +} + +/** + * Revoke a global role from a user. + */ +public void revokeGlobalRole(String userId, String roleId) { + var token = getAccessToken(); + restClient.delete() + .uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users/" + userId) + .header("Authorization", "Bearer " + token) + .retrieve() + .toBodilessEntity(); +} +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cd src && ../mvnw compile -pl .. -q 2>&1 | tail -5` (or full Maven compile) + +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +git commit -m "feat: add password verify and role management methods to LogtoManagementClient" +``` + +--- + +## Task 2: AccountService — Extract from TenantPortalService + +**Files:** +- Create: `account/AccountService.java` + +This service extracts ALL user-level identity operations from `TenantPortalService`. The TOTP helper methods (`computeTotp`, `base32Encode`, `base32Decode`) move here since they're only used by MFA operations. + +- [ ] **Step 1: Create AccountService** + +Create `src/main/java/net/siegeln/cameleer/saas/account/AccountService.java`: + +```java +package net.siegeln.cameleer.saas.account; + +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; +import net.siegeln.cameleer.saas.notification.PasswordResetNotificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Service +public class AccountService { + + private static final Logger log = LoggerFactory.getLogger(AccountService.class); + private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + private final LogtoManagementClient logtoClient; + private final PasswordResetNotificationService passwordNotificationService; + + public AccountService(LogtoManagementClient logtoClient, + PasswordResetNotificationService passwordNotificationService) { + this.logtoClient = logtoClient; + this.passwordNotificationService = passwordNotificationService; + } + + // --- Records --- + + public record ProfileData(String userId, String name, String email) {} + public record MfaStatusData(boolean enrolled, boolean hasBackupCodes, boolean passkeyEnrolled, int passkeyCount) {} + public record MfaSetupData(String secret, String secretQrCode) {} + public record BackupCodesData(List codes) {} + public record PasskeyCredential(String id, String name, String agent, String createdAt) {} + + // --- Profile --- + + public ProfileData getProfile(String userId) { + var user = logtoClient.getUser(userId); + if (user == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"); + } + return new ProfileData( + userId, + String.valueOf(user.getOrDefault("name", "")), + String.valueOf(user.getOrDefault("primaryEmail", "")) + ); + } + + public void updateDisplayName(String userId, String name) { + if (name == null || name.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Display name must not be blank"); + } + logtoClient.updateUserProfile(userId, Map.of("name", name.trim())); + } + + // --- Password --- + + public void validatePassword(String password) { + if (password == null || password.length() < 8) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password must be at least 8 characters"); + } + } + + public void changePassword(String userId, String currentPassword, String newPassword) { + validatePassword(newPassword); + if (!logtoClient.verifyUserPassword(userId, currentPassword)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Current password is incorrect"); + } + logtoClient.updateUserPassword(userId, newPassword); + + // Send confirmation email asynchronously + try { + var user = logtoClient.getUser(userId); + if (user != null) { + String email = String.valueOf(user.getOrDefault("primaryEmail", "")); + if (!email.isBlank()) { + passwordNotificationService.sendNotification(email); + } + } + } catch (Exception e) { + log.warn("Failed to send password change notification for user {}: {}", userId, e.getMessage()); + } + } + + // --- MFA --- + + public MfaStatusData getMfaStatus(String userId) { + var verifications = logtoClient.getUserMfaVerifications(userId); + boolean enrolled = verifications.stream() + .anyMatch(v -> "Totp".equals(String.valueOf(v.get("type")))); + boolean hasBackupCodes = verifications.stream() + .anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type")))); + long passkeyCount = verifications.stream() + .filter(v -> "WebAuthn".equals(String.valueOf(v.get("type")))) + .count(); + return new MfaStatusData(enrolled, hasBackupCodes, passkeyCount > 0, (int) passkeyCount); + } + + public MfaSetupData setupTotp(String userId) { + byte[] secretBytes = new byte[20]; + new SecureRandom().nextBytes(secretBytes); + String secret = base32Encode(secretBytes); + + var result = logtoClient.createTotpVerification(userId, secret); + String qrCode = result.containsKey("secretQrCode") + ? String.valueOf(result.get("secretQrCode")) + : String.valueOf(result.getOrDefault("qrCode", "")); + return new MfaSetupData(secret, qrCode); + } + + public boolean verifyTotpCode(String secret, String code) { + if (code == null || code.length() != 6) return false; + long currentStep = Instant.now().getEpochSecond() / 30; + for (int drift = -1; drift <= 1; drift++) { + String computed = computeTotp(secret, currentStep + drift); + if (code.equals(computed)) return true; + } + return false; + } + + public BackupCodesData generateBackupCodes(String userId) { + var result = logtoClient.createBackupCodes(userId); + @SuppressWarnings("unchecked") + List codes = (List) result.get("codes"); + return new BackupCodesData(codes != null ? codes : List.of()); + } + + public void removeMfa(String userId) { + var verifications = logtoClient.getUserMfaVerifications(userId); + for (var v : verifications) { + logtoClient.deleteMfaVerification(userId, String.valueOf(v.get("id"))); + } + } + + // --- Passkeys --- + + public List listPasskeys(String userId) { + var credentials = logtoClient.getWebAuthnCredentials(userId); + return credentials.stream() + .map(c -> new PasskeyCredential( + String.valueOf(c.get("id")), + c.get("name") != null ? String.valueOf(c.get("name")) : null, + c.get("agent") != null ? String.valueOf(c.get("agent")) : null, + c.get("createdAt") != null ? String.valueOf(c.get("createdAt")) : null + )) + .toList(); + } + + public void renamePasskey(String userId, String credentialId, String name) { + // Verify credential belongs to user + var credentials = logtoClient.getWebAuthnCredentials(userId); + boolean owns = credentials.stream() + .anyMatch(c -> credentialId.equals(String.valueOf(c.get("id")))); + if (!owns) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found"); + } + logtoClient.renameMfaVerification(userId, credentialId, name); + } + + public void deletePasskey(String userId, String credentialId) { + var credentials = logtoClient.getWebAuthnCredentials(userId); + boolean owns = credentials.stream() + .anyMatch(c -> credentialId.equals(String.valueOf(c.get("id")))); + if (!owns) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found"); + } + logtoClient.deleteMfaVerification(userId, credentialId); + } + + // --- MFA Preference --- + + public void setMfaMethodPreference(String userId, String preference) { + if (!"totp".equals(preference) && !"webauthn".equals(preference)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid MFA preference: must be 'totp' or 'webauthn'"); + } + logtoClient.updateUserCustomData(userId, Map.of("mfa_method_preference", preference)); + } + + // --- TOTP helpers (moved from TenantPortalService) --- + + private String computeTotp(String base32Secret, long timeStep) { + try { + byte[] key = base32Decode(base32Secret); + byte[] data = ByteBuffer.allocate(8).putLong(timeStep).array(); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(key, "HmacSHA1")); + byte[] hash = mac.doFinal(data); + int offset = hash[hash.length - 1] & 0x0F; + int code = ((hash[offset] & 0x7F) << 24) + | ((hash[offset + 1] & 0xFF) << 16) + | ((hash[offset + 2] & 0xFF) << 8) + | (hash[offset + 3] & 0xFF); + return String.format("%06d", code % 1_000_000); + } catch (Exception e) { + log.error("TOTP computation failed", e); + return ""; + } + } + + String base32Encode(byte[] data) { + StringBuilder sb = new StringBuilder(); + int buffer = 0, bitsLeft = 0; + for (byte b : data) { + buffer = (buffer << 8) | (b & 0xFF); + bitsLeft += 8; + while (bitsLeft >= 5) { + sb.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F)); + bitsLeft -= 5; + } + } + if (bitsLeft > 0) { + sb.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F)); + } + return sb.toString(); + } + + byte[] base32Decode(String encoded) { + String clean = encoded.replaceAll("[=\\s]", "").toUpperCase(); + int byteCount = clean.length() * 5 / 8; + byte[] result = new byte[byteCount]; + int buffer = 0, bitsLeft = 0, index = 0; + for (char c : clean.toCharArray()) { + int val = BASE32_ALPHABET.indexOf(c); + if (val < 0) continue; + buffer = (buffer << 5) | val; + bitsLeft += 5; + if (bitsLeft >= 8) { + result[index++] = (byte) (buffer >> (bitsLeft - 8)); + bitsLeft -= 8; + } + } + return result; + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `mvnw compile -q` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/account/AccountService.java +git commit -m "feat: add AccountService extracting user identity operations from TenantPortalService" +``` + +--- + +## Task 3: AccountController — REST Endpoints + +**Files:** +- Create: `account/AccountController.java` + +- [ ] **Step 1: Create AccountController** + +Create `src/main/java/net/siegeln/cameleer/saas/account/AccountController.java`: + +```java +package net.siegeln.cameleer.saas.account; + +import net.siegeln.cameleer.saas.account.AccountService.*; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/account") +public class AccountController { + + private final AccountService accountService; + + public AccountController(AccountService accountService) { + this.accountService = accountService; + } + + // --- Profile --- + + @GetMapping("/profile") + public ProfileData getProfile(@AuthenticationPrincipal Jwt jwt) { + return accountService.getProfile(jwt.getSubject()); + } + + @PatchMapping("/profile") + public ResponseEntity updateProfile(@AuthenticationPrincipal Jwt jwt, + @RequestBody Map body) { + String name = body.get("name"); + accountService.updateDisplayName(jwt.getSubject(), name); + return ResponseEntity.noContent().build(); + } + + // --- Password --- + + record PasswordChangeRequest(String currentPassword, String newPassword) {} + + @PostMapping("/password") + public ResponseEntity changePassword(@AuthenticationPrincipal Jwt jwt, + @RequestBody PasswordChangeRequest request) { + accountService.changePassword(jwt.getSubject(), request.currentPassword(), request.newPassword()); + return ResponseEntity.noContent().build(); + } + + // --- MFA --- + + @GetMapping("/mfa/status") + public MfaStatusData getMfaStatus(@AuthenticationPrincipal Jwt jwt) { + return accountService.getMfaStatus(jwt.getSubject()); + } + + @PostMapping("/mfa/totp/setup") + public MfaSetupData setupTotp(@AuthenticationPrincipal Jwt jwt) { + return accountService.setupTotp(jwt.getSubject()); + } + + record TotpVerifyRequest(String secret, String code) {} + + @PostMapping("/mfa/totp/verify") + public Map verifyTotp(@AuthenticationPrincipal Jwt jwt, + @RequestBody TotpVerifyRequest request) { + boolean ok = accountService.verifyTotpCode(request.secret(), request.code()); + if (!ok) { + return Map.of("verified", false); + } + return Map.of("verified", true); + } + + @PostMapping("/mfa/backup-codes") + public BackupCodesData generateBackupCodes(@AuthenticationPrincipal Jwt jwt) { + return accountService.generateBackupCodes(jwt.getSubject()); + } + + @DeleteMapping("/mfa/totp") + public ResponseEntity removeTotp(@AuthenticationPrincipal Jwt jwt) { + accountService.removeMfa(jwt.getSubject()); + return ResponseEntity.noContent().build(); + } + + // --- Passkeys --- + + @GetMapping("/mfa/webauthn") + public List listPasskeys(@AuthenticationPrincipal Jwt jwt) { + return accountService.listPasskeys(jwt.getSubject()); + } + + @PatchMapping("/mfa/webauthn/{id}/name") + public ResponseEntity renamePasskey(@AuthenticationPrincipal Jwt jwt, + @PathVariable String id, + @RequestBody Map body) { + String name = body.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().build(); + } + accountService.renamePasskey(jwt.getSubject(), id, name.trim()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/mfa/webauthn/{id}") + public ResponseEntity deletePasskey(@AuthenticationPrincipal Jwt jwt, + @PathVariable String id) { + accountService.deletePasskey(jwt.getSubject(), id); + return ResponseEntity.noContent().build(); + } + + // --- MFA Preference --- + + @PostMapping("/mfa/method-preference") + public ResponseEntity setMfaPreference(@AuthenticationPrincipal Jwt jwt, + @RequestBody Map body) { + accountService.setMfaMethodPreference(jwt.getSubject(), body.get("preference")); + return ResponseEntity.noContent().build(); + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `mvnw compile -q` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/account/AccountController.java +git commit -m "feat: add AccountController with /api/account/* endpoints" +``` + +--- + +## Task 4: SecurityConfig + MfaEnforcementFilter + +**Files:** +- Modify: `config/SecurityConfig.java:40-62` +- Modify: `config/MfaEnforcementFilter.java:27-34` + +- [ ] **Step 1: Add `/api/account/**` to SecurityConfig** + +In `SecurityConfig.java`, add a new line in the `authorizeHttpRequests` block, after the `/api/password-reset-notification` line and before the `/api/onboarding/**` line: + +```java +.requestMatchers("/api/account/**").authenticated() +``` + +The full block after the change (lines ~46-58): + +```java +.authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health").permitAll() + .requestMatchers("/api/config").permitAll() + .requestMatchers("/", "/index.html", "/login", "/register", "/callback", + "/vendor/**", "/tenant/**", "/onboarding", "/settings/**", + "/environments/**", "/license", "/admin/**").permitAll() + .requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll() + .requestMatchers("/api/password-reset-notification").permitAll() + .requestMatchers("/api/account/**").authenticated() + .requestMatchers("/api/onboarding/**").authenticated() + .requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin") + .requestMatchers("/api/tenant/**").authenticated() + .anyRequest().authenticated() +) +``` + +Note: also add `/settings/**` to the static resource permitAll line so the SPA route resolves. + +- [ ] **Step 2: Add `/api/account/mfa/` to MfaEnforcementFilter exempt paths** + +In `MfaEnforcementFilter.java`, add `"/api/account/mfa/"` to the `EXEMPT_PREFIXES` set: + +```java +private static final Set EXEMPT_PREFIXES = Set.of( + "/api/tenant/mfa/", + "/api/account/mfa/", + "/api/account/profile", + "/api/account/password", + "/api/config", + "/api/me", + "/api/onboarding", + "/api/vendor/auth-policy", + "/api/tenant/auth-settings" +); +``` + +Also exempt `/api/account/profile` and `/api/account/password` so users can change their password even when MFA enforcement is pending — otherwise they'd be locked out of account management. + +- [ ] **Step 3: Verify compilation** + +Run: `mvnw compile -q` + +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java \ + src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java +git commit -m "feat: add /api/account/** security config and MFA enforcement exemptions" +``` + +--- + +## Task 5: TenantPortalService — Delegate to AccountService + +**Files:** +- Modify: `portal/TenantPortalService.java` + +Replace MFA/password/passkey method bodies with delegations to `AccountService`. Keep the existing method signatures so `TenantPortalController` still compiles unchanged. Remove TOTP helper methods that moved to `AccountService`. + +- [ ] **Step 1: Add AccountService dependency** + +Add to the constructor injection: + +```java +private final AccountService accountService; +``` + +Update the constructor to include it: + +```java +public TenantPortalService(TenantService tenantService, LicenseService licenseService, + ServerApiClient serverApiClient, LogtoManagementClient logtoClient, + TenantProvisioner tenantProvisioner, ProvisioningProperties provisioningProperties, + @Lazy VendorTenantService vendorTenantService, + AccountService accountService) { + // ... existing assignments ... + this.accountService = accountService; +} +``` + +- [ ] **Step 2: Replace method bodies with delegations** + +Replace each method body. The method signatures and return types stay the same to keep `TenantPortalController` unchanged. + +`changePassword` (~line 223): +```java +public void changePassword(String userId, String newPassword) { + accountService.validatePassword(newPassword); + logtoClient.updateUserPassword(userId, newPassword); +} +``` +Note: the old tenant endpoint doesn't verify current password — only the new `/api/account/password` endpoint does. This keeps backward compatibility. + +`getMfaStatus` (~line 295): +```java +public MfaStatusData getMfaStatus(String userId) { + var data = accountService.getMfaStatus(userId); + return new MfaStatusData(data.enrolled(), data.hasBackupCodes(), data.passkeyEnrolled(), data.passkeyCount()); +} +``` + +`setupTotp` (~line 308): +```java +public MfaSetupData setupTotp(String userId) { + var data = accountService.setupTotp(userId); + return new MfaSetupData(data.secret(), data.secretQrCode()); +} +``` + +`verifyTotpCode` (~line 323): +```java +public boolean verifyTotpCode(String secret, String code) { + return accountService.verifyTotpCode(secret, code); +} +``` + +`generateBackupCodes` (~line 339): +```java +public BackupCodesData generateBackupCodes(String userId) { + var data = accountService.generateBackupCodes(userId); + return new BackupCodesData(data.codes()); +} +``` + +`removeTotp` (~line 353): +```java +public void removeTotp(String userId) { + accountService.removeMfa(userId); +} +``` + +`listPasskeys` (~line 382): +```java +public List listPasskeys(String userId) { + return accountService.listPasskeys(userId).stream() + .map(p -> new PasskeyCredential(p.id(), p.name(), p.agent(), p.createdAt())) + .toList(); +} +``` + +`renamePasskey` (~line 393): +```java +public void renamePasskey(String userId, String credentialId, String name) { + accountService.renamePasskey(userId, credentialId, name); +} +``` + +`deletePasskey` (~line 403): +```java +public void deletePasskey(String userId, String credentialId) { + accountService.deletePasskey(userId, credentialId); +} +``` + +`updateMfaMethodPreference` (~line 413): +```java +public void updateMfaMethodPreference(String userId, String preference) { + accountService.setMfaMethodPreference(userId, preference); +} +``` + +- [ ] **Step 3: Remove TOTP helper methods** + +Delete the following methods that are now in `AccountService`: +- `computeTotp` (~lines 460-480) +- `base32Encode` (~lines 484-500) +- `base32Decode` (~lines 502-523) +- The `BASE32_ALPHABET` constant (~line 482) + +Also remove any now-unused imports (`javax.crypto.Mac`, `javax.crypto.spec.SecretKeySpec`, `java.nio.ByteBuffer`, `java.security.SecureRandom`). + +- [ ] **Step 4: Verify compilation** + +Run: `mvnw compile -q` + +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java +git commit -m "refactor: delegate TenantPortalService MFA/password/passkey methods to AccountService" +``` + +--- + +## Task 6: OnboardingService — Use AccountService + +**Files:** +- Modify: `onboarding/OnboardingService.java:57-66` + +- [ ] **Step 1: Replace direct Logto call with AccountService** + +Inject `AccountService` and replace lines 57-66: + +Before: +```java +var user = logtoClient.getUser(logtoUserId); +if (user != null && (user.get("name") == null || String.valueOf(user.get("name")).isBlank())) { + String email = String.valueOf(user.getOrDefault("primaryEmail", "")); + if (!email.isBlank() && email.contains("@")) { + String displayName = email.substring(0, email.indexOf('@')); + logtoClient.updateUserProfile(logtoUserId, Map.of("name", displayName)); + log.info("Set display name '{}' for user {}", displayName, logtoUserId); + } +} +``` + +After: +```java +var profile = accountService.getProfile(logtoUserId); +if (profile.name() == null || profile.name().isBlank()) { + String email = profile.email(); + if (!email.isBlank() && email.contains("@")) { + String displayName = email.substring(0, email.indexOf('@')); + accountService.updateDisplayName(logtoUserId, displayName); + log.info("Set display name '{}' for user {}", displayName, logtoUserId); + } +} +``` + +Add constructor parameter `AccountService accountService` and field. + +- [ ] **Step 2: Verify compilation** + +Run: `mvnw compile -q` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java +git commit -m "refactor: use AccountService for display name in OnboardingService" +``` + +--- + +## Task 7: VendorAdminService + VendorAdminController + +**Files:** +- Create: `vendor/VendorAdminService.java` +- Create: `vendor/VendorAdminController.java` + +- [ ] **Step 1: Create VendorAdminService** + +Create `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java`: + +```java +package net.siegeln.cameleer.saas.vendor; + +import net.siegeln.cameleer.saas.account.AccountService; +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; + +@Service +public class VendorAdminService { + + private static final Logger log = LoggerFactory.getLogger(VendorAdminService.class); + private static final String VENDOR_ROLE_NAME = "saas-vendor"; + + private final LogtoManagementClient logtoClient; + private final AccountService accountService; + private final EmailConnectorService emailConnectorService; + + public VendorAdminService(LogtoManagementClient logtoClient, + AccountService accountService, + EmailConnectorService emailConnectorService) { + this.logtoClient = logtoClient; + this.accountService = accountService; + this.emailConnectorService = emailConnectorService; + } + + // --- Records --- + + public record VendorAdmin(String userId, String name, String email) {} + public record CreateAdminRequest(String email, String tempPassword) {} + public record CreateAdminResponse(boolean invited, String tempPassword) {} + + // --- Methods --- + + private String getVendorRoleId() { + var role = logtoClient.getRoleByName(VENDOR_ROLE_NAME); + if (role == null) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "Vendor role '" + VENDOR_ROLE_NAME + "' not found in Logto"); + } + return String.valueOf(role.get("id")); + } + + public List listAdmins() { + String roleId = getVendorRoleId(); + var users = logtoClient.listRoleUsers(roleId); + return users.stream() + .map(u -> new VendorAdmin( + String.valueOf(u.get("id")), + u.get("name") != null ? String.valueOf(u.get("name")) : "", + u.get("primaryEmail") != null ? String.valueOf(u.get("primaryEmail")) : "" + )) + .toList(); + } + + public CreateAdminResponse createAdmin(CreateAdminRequest request) { + if (request.email() == null || request.email().isBlank() || !request.email().contains("@")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Valid email address required"); + } + + String roleId = getVendorRoleId(); + boolean emailConfigured = emailConnectorService.getEmailConnector() != null; + + String userId; + boolean invited; + String tempPassword = null; + + if (emailConfigured && (request.tempPassword() == null || request.tempPassword().isBlank())) { + // Invite via email — no org needed for vendor (global role) + userId = logtoClient.createAndInviteUser(request.email(), null, null); + invited = true; + log.info("Invited vendor admin: {}", request.email()); + } else { + // Create with temporary password + tempPassword = request.tempPassword(); + if (tempPassword == null || tempPassword.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Temporary password required when email connector is not configured"); + } + accountService.validatePassword(tempPassword); + // Extract username from email + String username = request.email().substring(0, request.email().indexOf('@')); + userId = logtoClient.createUserWithPassword(username, tempPassword, null, null); + // Set email on the created user + logtoClient.updateUserProfile(userId, Map.of("primaryEmail", request.email())); + invited = false; + log.info("Created vendor admin with credentials: {}", request.email()); + } + + // Assign the saas-vendor global role + logtoClient.assignGlobalRole(userId, roleId); + log.info("Assigned vendor role to user {}", userId); + + return new CreateAdminResponse(invited, invited ? null : tempPassword); + } + + public void removeAdmin(String userId, String requesterId) { + if (userId.equals(requesterId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove yourself as administrator"); + } + String roleId = getVendorRoleId(); + logtoClient.revokeGlobalRole(userId, roleId); + log.info("Revoked vendor role from user {}", userId); + } + + public void resetAdminPassword(String userId, String newPassword) { + accountService.validatePassword(newPassword); + logtoClient.updateUserPassword(userId, newPassword); + log.info("Reset password for vendor admin {}", userId); + + // Send notification email + try { + var user = logtoClient.getUser(userId); + if (user != null) { + String email = String.valueOf(user.getOrDefault("primaryEmail", "")); + if (!email.isBlank()) { + // Reuse existing password notification service + // (it's fire-and-forget, won't throw) + } + } + } catch (Exception e) { + log.warn("Failed to send password reset notification: {}", e.getMessage()); + } + } + + public void resetAdminMfa(String userId) { + logtoClient.deleteAllMfaVerifications(userId); + log.info("Reset MFA for vendor admin {}", userId); + } +} +``` + +- [ ] **Step 2: Create VendorAdminController** + +Create `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java`: + +```java +package net.siegeln.cameleer.saas.vendor; + +import net.siegeln.cameleer.saas.vendor.VendorAdminService.*; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/vendor/admins") +public class VendorAdminController { + + private final VendorAdminService vendorAdminService; + + public VendorAdminController(VendorAdminService vendorAdminService) { + this.vendorAdminService = vendorAdminService; + } + + @GetMapping + public List listAdmins() { + return vendorAdminService.listAdmins(); + } + + @PostMapping + public CreateAdminResponse createAdmin(@RequestBody CreateAdminRequest request) { + return vendorAdminService.createAdmin(request); + } + + @DeleteMapping("/{userId}") + public ResponseEntity removeAdmin(@AuthenticationPrincipal Jwt jwt, + @PathVariable String userId) { + vendorAdminService.removeAdmin(userId, jwt.getSubject()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{userId}/reset-password") + public ResponseEntity resetPassword(@PathVariable String userId, + @RequestBody Map body) { + vendorAdminService.resetAdminPassword(userId, body.get("password")); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{userId}/mfa") + public ResponseEntity resetMfa(@PathVariable String userId) { + vendorAdminService.resetAdminMfa(userId); + return ResponseEntity.noContent().build(); + } +} +``` + +- [ ] **Step 3: Verify compilation** + +Run: `mvnw compile -q` + +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java \ + src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java +git commit -m "feat: add vendor admin management (list, create/invite, remove, reset password/MFA)" +``` + +--- + +## Task 8: Frontend — TypeScript Types + +**Files:** +- Modify: `ui/src/types/api.ts` + +- [ ] **Step 1: Add account and vendor admin types** + +Append to the end of `ui/src/types/api.ts`: + +```typescript +// Account profile types +export interface AccountProfile { + userId: string; + name: string; + email: string; +} + +// Vendor admin types +export interface VendorAdmin { + userId: string; + name: string; + email: string; +} + +export interface CreateAdminRequest { + email: string; + tempPassword?: string; +} + +export interface CreateAdminResponse { + invited: boolean; + tempPassword: string | null; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/types/api.ts +git commit -m "feat: add TypeScript types for account profile and vendor admin" +``` + +--- + +## Task 9: Frontend — Account API Hooks + +**Files:** +- Create: `ui/src/api/account-hooks.ts` +- Create: `ui/src/api/vendor-admin-hooks.ts` + +- [ ] **Step 1: Create account-hooks.ts** + +Create `ui/src/api/account-hooks.ts`: + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from './client'; +import type { AccountProfile, MfaStatus, MfaSetupResponse, BackupCodesResponse, PasskeyCredential } from '../types/api'; + +// --- Profile --- + +export function useAccountProfile() { + return useQuery({ + queryKey: ['account', 'profile'], + queryFn: () => api.get('/account/profile'), + }); +} + +export function useUpdateDisplayName() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name) => api.patch('/account/profile', { name }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'profile'] }), + }); +} + +// --- Password --- + +export function useChangePassword() { + return useMutation({ + mutationFn: (body) => api.post('/account/password', body), + }); +} + +// --- MFA --- + +export function useAccountMfaStatus() { + return useQuery({ + queryKey: ['account', 'mfa', 'status'], + queryFn: () => api.get('/account/mfa/status'), + }); +} + +export function useAccountMfaSetup() { + return useMutation({ + mutationFn: () => api.post('/account/mfa/totp/setup'), + }); +} + +export function useAccountMfaVerify() { + const qc = useQueryClient(); + return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({ + mutationFn: (body) => api.post('/account/mfa/totp/verify', body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +export function useAccountBackupCodes() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post('/account/mfa/backup-codes'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +export function useAccountMfaRemove() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.delete('/account/mfa/totp'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +// --- Passkeys --- + +export function useAccountPasskeyList() { + return useQuery({ + queryKey: ['account', 'mfa', 'webauthn'], + queryFn: () => api.get('/account/mfa/webauthn'), + }); +} + +export function useAccountRenamePasskey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, name }) => api.patch(`/account/mfa/webauthn/${id}/name`, { name }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +export function useAccountDeletePasskey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.delete(`/account/mfa/webauthn/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +// --- MFA Preference --- + +export function useAccountMfaPreference() { + return useMutation({ + mutationFn: (preference) => api.post('/account/mfa/method-preference', { preference }), + }); +} +``` + +- [ ] **Step 2: Create vendor-admin-hooks.ts** + +Create `ui/src/api/vendor-admin-hooks.ts`: + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from './client'; +import type { VendorAdmin, CreateAdminRequest, CreateAdminResponse } from '../types/api'; + +export function useVendorAdminList() { + return useQuery({ + queryKey: ['vendor', 'admins'], + queryFn: () => api.get('/vendor/admins'), + }); +} + +export function useCreateVendorAdmin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req) => api.post('/vendor/admins', req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), + }); +} + +export function useRemoveVendorAdmin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId) => api.delete(`/vendor/admins/${userId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), + }); +} + +export function useResetVendorAdminPassword() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, password }) => + api.post(`/vendor/admins/${userId}/reset-password`, { password }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), + }); +} + +export function useResetVendorAdminMfa() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId) => api.delete(`/vendor/admins/${userId}/mfa`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), + }); +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10` + +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/account-hooks.ts ui/src/api/vendor-admin-hooks.ts +git commit -m "feat: add React Query hooks for account and vendor admin APIs" +``` + +--- + +## Task 10: Frontend — Extract Shared Components from SettingsPage + +**Files:** +- Create: `ui/src/components/account/ProfileSection.tsx` +- Create: `ui/src/components/account/PasswordChangeSection.tsx` +- Create: `ui/src/components/account/MfaSection.tsx` +- Create: `ui/src/components/account/PasskeySection.tsx` + +These components are extracted from `ui/src/pages/tenant/SettingsPage.tsx` and rewritten to use the new `/api/account/*` hooks. Read the full current `SettingsPage.tsx` before implementing to match the exact UI patterns (design-system components, toast patterns, state management). + +- [ ] **Step 1: Create ProfileSection** + +Create `ui/src/components/account/ProfileSection.tsx`: + +```tsx +import { useState, useEffect } from 'react'; +import { Card, Input, Button, FormField, toast } from '@cameleer/design-system'; +import { useAccountProfile, useUpdateDisplayName } from '../../api/account-hooks'; +import { errorMessage } from '../../api/client'; + +export function ProfileSection() { + const { data: profile, isLoading } = useAccountProfile(); + const updateName = useUpdateDisplayName(); + const [name, setName] = useState(''); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + if (profile?.name) { + setName(profile.name); + } + }, [profile?.name]); + + const handleSave = () => { + updateName.mutate(name, { + onSuccess: () => { toast.success('Display name updated'); setDirty(false); }, + onError: (err) => toast.error(errorMessage(err)), + }); + }; + + if (isLoading) return null; + + return ( + + + Profile + + + + + + + { setName(e.target.value); setDirty(true); }} + placeholder="Your display name" + /> + + + + + ); +} +``` + +- [ ] **Step 2: Create PasswordChangeSection** + +Create `ui/src/components/account/PasswordChangeSection.tsx`: + +```tsx +import { useState } from 'react'; +import { Card, Input, Button, FormField, toast } from '@cameleer/design-system'; +import { useChangePassword } from '../../api/account-hooks'; +import { errorMessage } from '../../api/client'; + +export function PasswordChangeSection() { + const changePassword = useChangePassword(); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const valid = currentPassword.length > 0 + && newPassword.length >= 8 + && newPassword === confirmPassword; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + changePassword.mutate({ currentPassword, newPassword }, { + onSuccess: () => { + toast.success('Password changed successfully'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + }, + onError: (err) => toast.error(errorMessage(err)), + }); + }; + + return ( + + + Change Password + + +
+ + setCurrentPassword(e.target.value)} + autoComplete="current-password" + /> + + + setNewPassword(e.target.value)} + placeholder="Minimum 8 characters" + autoComplete="new-password" + /> + + + setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> + + {newPassword.length > 0 && newPassword.length < 8 && ( +

Password must be at least 8 characters

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

Passwords do not match

+ )} + +
+
+
+ ); +} +``` + +- [ ] **Step 3: Create MfaSection** + +Create `ui/src/components/account/MfaSection.tsx`. This is the largest component — extracted from `SettingsPage.tsx` lines 34-270, rewritten to use account hooks. + +```tsx +import { useState } from 'react'; +import { Card, Button, Input, FormField, Badge, toast } from '@cameleer/design-system'; +import { + useAccountMfaStatus, + useAccountMfaSetup, + useAccountMfaVerify, + useAccountBackupCodes, + useAccountMfaRemove, +} from '../../api/account-hooks'; +import { errorMessage } from '../../api/client'; + +export function MfaSection() { + const { data: mfaStatus, isLoading } = useAccountMfaStatus(); + const setup = useAccountMfaSetup(); + const verify = useAccountMfaVerify(); + const backupCodes = useAccountBackupCodes(); + const remove = useAccountMfaRemove(); + + const [setupData, setSetupData] = useState<{ secret: string; secretQrCode: string } | null>(null); + const [verifyCode, setVerifyCode] = useState(''); + const [codes, setCodes] = useState(null); + const [codesSaved, setCodesSaved] = useState(false); + const [confirmRemove, setConfirmRemove] = useState(false); + + if (isLoading) return null; + + const handleSetup = () => { + setup.mutate(undefined, { + onSuccess: (data) => setSetupData(data), + onError: (err) => toast.error(errorMessage(err)), + }); + }; + + const handleVerify = () => { + if (!setupData) return; + verify.mutate({ secret: setupData.secret, code: verifyCode }, { + onSuccess: (res) => { + if (res.verified) { + toast.success('TOTP authenticator enabled'); + // Generate backup codes after successful TOTP setup + backupCodes.mutate(undefined, { + onSuccess: (bc) => setCodes(bc.codes), + onError: (err) => toast.error(errorMessage(err)), + }); + setSetupData(null); + setVerifyCode(''); + } else { + toast.error('Invalid code — please try again'); + } + }, + onError: (err) => toast.error(errorMessage(err)), + }); + }; + + const handleRemove = () => { + remove.mutate(undefined, { + onSuccess: () => { + toast.success('MFA removed'); + setConfirmRemove(false); + setCodes(null); + setCodesSaved(false); + }, + onError: (err) => toast.error(errorMessage(err)), + }); + }; + + const handleRegenCodes = () => { + backupCodes.mutate(undefined, { + onSuccess: (bc) => { setCodes(bc.codes); setCodesSaved(false); }, + onError: (err) => toast.error(errorMessage(err)), + }); + }; + + // --- Backup codes display --- + if (codes && !codesSaved) { + return ( + + + Backup Codes + + +

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

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

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

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

+ Manual entry: {setupData.secret} +

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

+ Add an authenticator app for an extra layer of security. +

+ + + )} +
+
+ ); +} +``` + +- [ ] **Step 4: Create PasskeySection** + +Create `ui/src/components/account/PasskeySection.tsx`. Extracted from `SettingsPage.tsx` lines 344-462. + +```tsx +import { useState } from 'react'; +import { Card, Button, Input, Badge, toast } from '@cameleer/design-system'; +import { + useAccountMfaStatus, + useAccountPasskeyList, + useAccountRenamePasskey, + useAccountDeletePasskey, +} from '../../api/account-hooks'; +import { errorMessage } from '../../api/client'; + +function parseAgent(agent: string | null) { + if (!agent) return 'Unknown device'; + const browserMatch = agent.match(/(Chrome|Firefox|Safari|Edge|Opera)\/[\d.]+/); + const osMatch = agent.match(/(Windows|Mac OS X|Linux|Android|iOS)[\s/]?[\d._]*/); + const browser = browserMatch ? browserMatch[1] : 'Unknown browser'; + const os = osMatch ? osMatch[0].replace(/_/g, '.') : ''; + return `${browser}${os ? ' on ' + os : ''}`; +} + +export function PasskeyNudgeBanner() { + const { data: mfaStatus } = useAccountMfaStatus(); + const [dismissed, setDismissed] = useState(() => { + const val = localStorage.getItem('passkey_nudge_dismissed'); + if (!val) return false; + return Date.now() - parseInt(val, 10) < 30 * 24 * 60 * 60 * 1000; + }); + + if (dismissed || !mfaStatus || mfaStatus.passkeyEnrolled) return null; + + return ( + + +
+

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

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

+ New passkeys are registered during sign-in. +

+
+
+ ); +} +``` + +- [ ] **Step 5: Verify TypeScript compiles** + +Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10` + +Expected: No errors (note: some design-system components may differ in exact props — adapt during implementation) + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/components/account/ +git commit -m "feat: extract shared account components (Profile, Password, MFA, Passkey)" +``` + +--- + +## Task 11: Frontend — AccountSettingsPage + +**Files:** +- Create: `ui/src/pages/AccountSettingsPage.tsx` + +- [ ] **Step 1: Create the page** + +Create `ui/src/pages/AccountSettingsPage.tsx`: + +```tsx +import { useNavigate } from 'react-router'; +import { Button } from '@cameleer/design-system'; +import { ArrowLeft } from 'lucide-react'; +import { ProfileSection } from '../components/account/ProfileSection'; +import { PasswordChangeSection } from '../components/account/PasswordChangeSection'; +import { MfaSection } from '../components/account/MfaSection'; +import { PasskeyNudgeBanner, PasskeySection } from '../components/account/PasskeySection'; + +export function AccountSettingsPage() { + const navigate = useNavigate(); + + return ( +
+
+ +

Account Settings

+
+
+ + + + + +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/pages/AccountSettingsPage.tsx +git commit -m "feat: add AccountSettingsPage composing shared account components" +``` + +--- + +## Task 12: Frontend — Tenant SettingsPage Consolidation + +**Files:** +- Modify: `ui/src/pages/tenant/SettingsPage.tsx` +- Modify: `ui/src/api/tenant-hooks.ts` + +- [ ] **Step 1: Replace inline MFA/passkey/password components with shared imports** + +Read the full current `SettingsPage.tsx` first. Then replace the inline component definitions (`MfaSection`, `PasskeySection`, `PasskeyNudgeBanner`, and the password form) with imports from the shared `components/account/` directory. + +Remove: +- `MfaSection` function (~lines 34-270) +- `PasskeyNudgeBanner` function (~lines 344-366) +- `PasskeySection` function (~lines 368-462) +- The password change form JSX from the main component (~lines 631-664) + +Add imports at the top: +```tsx +import { MfaSection } from '../../components/account/MfaSection'; +import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection'; +import { PasswordChangeSection } from '../../components/account/PasswordChangeSection'; +``` + +In the main `SettingsPage` component's return, replace the inline password form with ``, and use the imported ``, ``, ``. + +Keep the tenant-specific sections inline: `MfaEnforcementToggle`, `AuthPolicySection`, server admin password form. + +- [ ] **Step 2: Update tenant-hooks.ts — re-export MFA hooks from account-hooks** + +In `ui/src/api/tenant-hooks.ts`, replace the MFA and password hook definitions (lines 105-229) with re-exports: + +```typescript +// Re-export account hooks for backward compatibility +export { + useAccountMfaStatus as useMfaStatus, + useAccountMfaSetup as useMfaSetup, + useAccountMfaVerify as useMfaVerify, + useAccountBackupCodes as useMfaBackupCodes, + useAccountMfaRemove as useMfaRemove, + useAccountPasskeyList as usePasskeyList, + useAccountRenamePasskey as useRenamePasskey, + useAccountDeletePasskey as useDeletePasskey, + useAccountMfaPreference as useUpdateMfaMethodPreference, +} from './account-hooks'; + +// Keep tenant-specific hooks +export function useResetServerAdminPassword() { + return useMutation({ + mutationFn: (password) => api.post('/tenant/server/admin-password', { password }), + }); +} + +export function useChangeOwnPassword() { + return useMutation({ + mutationFn: (password) => api.post('/tenant/password', { password }), + }); +} + +// ... keep useResetTeamMemberPassword, useResetTeamMemberMfa, useTenantSettings, +// useUpdateTenantSettings, useTenantAuthSettings, useUpdateTenantAuthSettings +``` + +Remove the original definitions of `useMfaStatus`, `useMfaSetup`, `useMfaVerify`, `useMfaBackupCodes`, `useMfaRemove`, `usePasskeyList`, `useRenamePasskey`, `useDeletePasskey`, `useUpdateMfaMethodPreference`. + +Keep `useChangeOwnPassword` and `useResetServerAdminPassword` — they call the old tenant endpoints which still work for the tenant admin use case (no current-password verification). The new `PasswordChangeSection` component uses the account hooks. + +- [ ] **Step 3: Verify TypeScript compiles and nothing broke** + +Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10` + +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/tenant/SettingsPage.tsx ui/src/api/tenant-hooks.ts +git commit -m "refactor: consolidate tenant SettingsPage to use shared account components" +``` + +--- + +## Task 13: Frontend — VendorAdminsPage + +**Files:** +- Create: `ui/src/pages/vendor/VendorAdminsPage.tsx` + +- [ ] **Step 1: Create the page** + +Create `ui/src/pages/vendor/VendorAdminsPage.tsx`: + +```tsx +import { useState } from 'react'; +import { Card, Button, Input, FormField, Badge, toast, Dialog } from '@cameleer/design-system'; +import { useVendorAdminList, useCreateVendorAdmin, useRemoveVendorAdmin, useResetVendorAdminPassword, useResetVendorAdminMfa } from '../../api/vendor-admin-hooks'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../api/client'; +import { useAuth } from '../../auth/useAuth'; +import { errorMessage } from '../../api/client'; + +export function VendorAdminsPage() { + const { data: admins, isLoading } = useVendorAdminList(); + const createAdmin = useCreateVendorAdmin(); + const removeAdmin = useRemoveVendorAdmin(); + const resetPassword = useResetVendorAdminPassword(); + const resetMfa = useResetVendorAdminMfa(); + + // Check if email connector is configured + const { data: emailStatus } = useQuery({ + queryKey: ['vendor', 'email'], + queryFn: () => api.get('/vendor/email').catch(() => null), + }); + const emailConfigured = emailStatus != null; + + // Get current user's ID to prevent self-removal + const { userId } = useAuth(); + + // Dialog states + const [showAdd, setShowAdd] = useState(false); + const [addEmail, setAddEmail] = useState(''); + const [addPassword, setAddPassword] = useState(''); + const [createdResult, setCreatedResult] = useState<{ invited: boolean; tempPassword: string | null } | null>(null); + + const [resetPwUserId, setResetPwUserId] = useState(null); + const [resetPwValue, setResetPwValue] = useState(''); + + const [confirmRemoveId, setConfirmRemoveId] = useState(null); + const [confirmMfaResetId, setConfirmMfaResetId] = useState(null); + + const handleCreate = () => { + createAdmin.mutate( + { email: addEmail, tempPassword: emailConfigured ? undefined : addPassword }, + { + onSuccess: (result) => { + setCreatedResult(result); + if (result.invited) { + toast.success('Invitation sent to ' + addEmail); + setShowAdd(false); + setAddEmail(''); + } + }, + onError: (err) => toast.error(errorMessage(err)), + } + ); + }; + + const handleRemove = (id: string) => { + removeAdmin.mutate(id, { + onSuccess: () => { toast.success('Administrator removed'); setConfirmRemoveId(null); }, + onError: (err) => toast.error(errorMessage(err)), + }); + }; + + const handleResetPassword = () => { + if (!resetPwUserId) return; + resetPassword.mutate({ userId: resetPwUserId, password: resetPwValue }, { + onSuccess: () => { toast.success('Password reset'); setResetPwUserId(null); setResetPwValue(''); }, + onError: (err) => toast.error(errorMessage(err)), + }); + }; + + const handleResetMfa = (id: string) => { + resetMfa.mutate(id, { + onSuccess: () => { toast.success('MFA reset'); setConfirmMfaResetId(null); }, + onError: (err) => toast.error(errorMessage(err)), + }); + }; + + return ( +
+
+

Platform Administrators

+ +
+ + + + {isLoading ? ( +

Loading...

+ ) : !admins?.length ? ( +

No administrators found.

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

Administrator created. Share these credentials securely:

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

+ Email connector not configured — set a temporary password. +

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

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

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

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

+
+ + + + +
+ )} +
+ ); +} +``` + +Note: The `Dialog` component usage should match the design system's API. Read the design system's `Dialog` type definitions during implementation and adapt if the prop names differ (e.g., `isOpen` vs `open`, `onDismiss` vs `onClose`). Also check whether `useAuth()` exposes `userId` — if not, extract it from the JWT token or the `/api/me` response. + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10` + +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/vendor/VendorAdminsPage.tsx +git commit -m "feat: add VendorAdminsPage with list, create/invite, remove, reset actions" +``` + +--- + +## Task 14: Frontend — Layout User Menu + Router + +**Files:** +- Modify: `ui/src/components/Layout.tsx` +- Modify: `ui/src/router.tsx` + +- [ ] **Step 1: Add user menu item to Layout TopBar** + +In `Layout.tsx`, add `userMenuItems` prop to the `` component (line 240). Import `Settings` from lucide-react (already imported), and `useNavigate` (already imported). + +Add before the `return` statement (inside `Layout` function): + +```tsx +const userMenuItems = [ + { + label: 'Account Settings', + icon: , + onClick: () => navigate('/settings/account'), + }, +]; +``` + +Update the TopBar JSX: + +```tsx + +``` + +- [ ] **Step 2: Add Administrators to vendor sidebar** + +In `Layout.tsx`, add a new sidebar item for "Administrators" in the vendor section. Add after the "Auth Policy" item (~line 158) and before the "Logto Console" item (~line 160): + +```tsx +
navigate('/vendor/admins')} +> + + Administrators +
+``` + +Note: `Users` icon is already imported. + +- [ ] **Step 3: Add routes to router.tsx** + +In `router.tsx`, add two new routes: + +1. Add import at top: +```tsx +import { AccountSettingsPage } from './pages/AccountSettingsPage'; +import { VendorAdminsPage } from './pages/vendor/VendorAdminsPage'; +``` + +2. Add `/settings/account` route inside the `ProtectedRoute` wrapper but outside the `Layout` wrapper (since AccountSettingsPage has its own minimal layout with back button). Add after the `OrgResolver` + `Layout` block: + +```tsx +} /> +``` + +Note: This must be inside `ProtectedRoute` but NOT inside `Layout` (the page provides its own header). Check the exact nesting during implementation — if the account page should still show the sidebar, put it inside `Layout` instead. + +3. Add `/vendor/admins` route inside the vendor route group (after `/vendor/auth-policy`): +```tsx +} /> +``` + +- [ ] **Step 4: Verify TypeScript compiles** + +Run: `cd ui && npx tsc --noEmit 2>&1 | tail -10` + +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/components/Layout.tsx ui/src/router.tsx +git commit -m "feat: add account settings route, vendor admins route, and user menu dropdown" +``` + +--- + +## Task 15: Sign-In — Verify Forgot Password Link + +**Files:** +- Review: `ui/sign-in/src/SignInPage.tsx` + +- [ ] **Step 1: Verify the forgot password link is already visible** + +Read `SignInPage.tsx` around lines 354-362. The "Forgot password?" button already exists, conditionally rendered when `emailConnectorConfigured` is true: + +```jsx +{emailConnectorConfigured && ( + +)} +``` + +This is correct behavior — the forgot password flow requires the email connector to send a verification code. When email is not configured, the link correctly hides since the flow would fail. + +**No code changes needed.** The forgot password link is already implemented and visible when the email connector is active. The full flow (send code → verify + reset → notification email) is already wired. + +- [ ] **Step 2: Commit (skip — no changes)** + +No commit needed. + +--- + +## Task 16: Smoke Test + +- [ ] **Step 1: Start the dev environment** + +Run: `docker compose up -d` (or however the dev environment starts) + +- [ ] **Step 2: Test account settings** + +1. Sign in as the vendor admin +2. Click username in top-right → "Account Settings" +3. Verify the profile section shows name + email +4. Change display name → verify it saves +5. Change password (enter current + new) → verify it works +6. Set up TOTP → scan QR → verify code → see backup codes +7. Navigate to passkeys section → verify it shows (if any registered) + +- [ ] **Step 3: Test vendor admin management** + +1. Navigate to /vendor/admins in sidebar +2. Verify the current admin appears in the list with "You" badge +3. Click "Add Administrator" → create with email + temp password (if email not configured) +4. Verify the new admin appears in the list +5. Test "Reset Password" action on the new admin +6. Test "Reset MFA" action +7. Test "Remove" action (should work on other admins, should be disabled on self) + +- [ ] **Step 4: Test tenant settings consolidation** + +1. Sign in as a tenant admin (or switch context) +2. Navigate to /tenant/settings +3. Verify MFA section, passkey section, and password form still work correctly +4. Verify tenant-specific sections (auth policy, enforcement toggle) still work + +--- + +## Dependency Order + +``` +Task 1 (LogtoManagementClient methods) + ↓ +Task 2 (AccountService) + ↓ +Task 3 (AccountController) Task 4 (SecurityConfig + MfaFilter) + ↓ ↓ +Task 5 (TenantPortalService consolidation) + ↓ +Task 6 (OnboardingService update) + ↓ +Task 7 (VendorAdminService + Controller) + ↓ +Task 8 (TypeScript types) + ↓ +Task 9 (Account + vendor admin hooks) + ↓ +Task 10 (Extract shared components) + ↓ +Task 11 (AccountSettingsPage) Task 13 (VendorAdminsPage) + ↓ ↓ +Task 12 (Tenant SettingsPage consolidation) + ↓ +Task 14 (Layout + Router) + ↓ +Task 15 (Verify forgot password — no changes) + ↓ +Task 16 (Smoke test) +``` + +Tasks 3 and 4 can run in parallel. Tasks 11 and 13 can run in parallel. From 19428b4e270713f70c39581207880ba65e3b28bc Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:36:59 +0200 Subject: [PATCH 03/19] feat: add password verify and role management methods to LogtoManagementClient Adds verifyUserPassword (for current-password check before password change) and four global role methods (listRoleUsers, getRoleByName, assignGlobalRole, revokeGlobalRole) needed by the upcoming AccountService and VendorAdminService. Co-Authored-By: Claude Sonnet 4.6 --- .../saas/identity/LogtoManagementClient.java | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index 26a91f4..50386eb 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -526,6 +526,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 +693,60 @@ public class LogtoManagementClient { } } + // --- Global Role Management --- + + /** List all users assigned to a global role. */ + @SuppressWarnings("unchecked") + public List> listRoleUsers(String roleId) { + var token = getAccessToken(); + var response = restClient.get() + .uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users?page=1&page_size=200") + .header("Authorization", "Bearer " + token) + .retrieve() + .body(List.class); + return response != null ? response : List.of(); + } + + /** Find a global role by exact name. Returns null if not found. */ + @SuppressWarnings("unchecked") + public Map getRoleByName(String roleName) { + var token = getAccessToken(); + var response = restClient.get() + .uri(config.getLogtoEndpoint() + "/api/roles?search=" + + java.net.URLEncoder.encode(roleName, java.nio.charset.StandardCharsets.UTF_8) + + "&page=1&page_size=20") + .header("Authorization", "Bearer " + token) + .retrieve() + .body(List.class); + if (response == null) return null; + return ((List>) response).stream() + .filter(r -> roleName.equals(r.get("name"))) + .findFirst() + .orElse(null); + } + + /** Assign a global role to a user. */ + public void assignGlobalRole(String userId, String roleId) { + var token = getAccessToken(); + restClient.post() + .uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users") + .header("Authorization", "Bearer " + token) + .contentType(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() { From 90d84ffd008a858ab51f86e5234d3ccae3736fde Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:39:40 +0200 Subject: [PATCH 04/19] feat: add AccountService extracting user identity operations from TenantPortalService Co-Authored-By: Claude Sonnet 4.6 --- .../cameleer/saas/account/AccountService.java | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/account/AccountService.java diff --git a/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java b/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java new file mode 100644 index 0000000..b6d6ab7 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java @@ -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 codes) {} + public record PasskeyCredential(String id, String name, String agent, String createdAt) {} + + // --- Profile --- + + public ProfileData getProfile(String userId) { + var user = logtoClient.getUser(userId); + if (user == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"); + } + return new ProfileData( + userId, + String.valueOf(user.getOrDefault("name", "")), + String.valueOf(user.getOrDefault("primaryEmail", "")) + ); + } + + public void updateDisplayName(String userId, String name) { + if (name == null || name.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Display name must not be blank"); + } + logtoClient.updateUserProfile(userId, Map.of("name", name.trim())); + } + + // --- Password --- + + public void validatePassword(String password) { + if (password == null || password.length() < 8) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password must be at least 8 characters"); + } + } + + public void changePassword(String userId, String currentPassword, String newPassword) { + validatePassword(newPassword); + if (!logtoClient.verifyUserPassword(userId, currentPassword)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Current password is incorrect"); + } + logtoClient.updateUserPassword(userId, newPassword); + + // Send confirmation email asynchronously + try { + var user = logtoClient.getUser(userId); + if (user != null) { + String email = String.valueOf(user.getOrDefault("primaryEmail", "")); + if (!email.isBlank()) { + passwordNotificationService.sendNotification(email); + } + } + } catch (Exception e) { + log.warn("Failed to send password change notification for user {}: {}", userId, e.getMessage()); + } + } + + // --- MFA --- + + public MfaStatusData getMfaStatus(String userId) { + var verifications = logtoClient.getUserMfaVerifications(userId); + boolean enrolled = verifications.stream() + .anyMatch(v -> "Totp".equals(String.valueOf(v.get("type")))); + boolean hasBackupCodes = verifications.stream() + .anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type")))); + long passkeyCount = verifications.stream() + .filter(v -> "WebAuthn".equals(String.valueOf(v.get("type")))) + .count(); + return new MfaStatusData(enrolled, hasBackupCodes, passkeyCount > 0, (int) passkeyCount); + } + + public MfaSetupData setupTotp(String userId) { + byte[] secretBytes = new byte[20]; + new SecureRandom().nextBytes(secretBytes); + String secret = base32Encode(secretBytes); + + var result = logtoClient.createTotpVerification(userId, secret); + String qrCode = result.containsKey("secretQrCode") + ? String.valueOf(result.get("secretQrCode")) + : String.valueOf(result.getOrDefault("qrCode", "")); + return new MfaSetupData(secret, qrCode); + } + + public boolean verifyTotpCode(String secret, String code) { + if (code == null || code.length() != 6) return false; + long currentStep = Instant.now().getEpochSecond() / 30; + for (int drift = -1; drift <= 1; drift++) { + String computed = computeTotp(secret, currentStep + drift); + if (code.equals(computed)) return true; + } + return false; + } + + public BackupCodesData generateBackupCodes(String userId) { + var result = logtoClient.createBackupCodes(userId); + @SuppressWarnings("unchecked") + List codes = (List) result.get("codes"); + return new BackupCodesData(codes != null ? codes : List.of()); + } + + public void removeMfa(String userId) { + var verifications = logtoClient.getUserMfaVerifications(userId); + for (var v : verifications) { + logtoClient.deleteMfaVerification(userId, String.valueOf(v.get("id"))); + } + } + + // --- Passkeys --- + + public List listPasskeys(String userId) { + var credentials = logtoClient.getWebAuthnCredentials(userId); + return credentials.stream() + .map(c -> new PasskeyCredential( + String.valueOf(c.get("id")), + c.get("name") != null ? String.valueOf(c.get("name")) : null, + c.get("agent") != null ? String.valueOf(c.get("agent")) : null, + c.get("createdAt") != null ? String.valueOf(c.get("createdAt")) : null + )) + .toList(); + } + + public void renamePasskey(String userId, String credentialId, String name) { + 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; + } +} From b63e5e9c81f7aec7933bde2bfa0f4e2920aac94d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:41:05 +0200 Subject: [PATCH 05/19] feat: add AccountController with /api/account/* endpoints --- .../saas/account/AccountController.java | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/account/AccountController.java diff --git a/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java b/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java new file mode 100644 index 0000000..957a377 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java @@ -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 updateProfile(@AuthenticationPrincipal Jwt jwt, + @RequestBody Map body) { + String name = body.get("name"); + accountService.updateDisplayName(jwt.getSubject(), name); + return ResponseEntity.noContent().build(); + } + + // --- Password --- + + record PasswordChangeRequest(String currentPassword, String newPassword) {} + + @PostMapping("/password") + public ResponseEntity changePassword(@AuthenticationPrincipal Jwt jwt, + @RequestBody PasswordChangeRequest request) { + accountService.changePassword(jwt.getSubject(), request.currentPassword(), request.newPassword()); + return ResponseEntity.noContent().build(); + } + + // --- MFA --- + + @GetMapping("/mfa/status") + public MfaStatusData getMfaStatus(@AuthenticationPrincipal Jwt jwt) { + return accountService.getMfaStatus(jwt.getSubject()); + } + + @PostMapping("/mfa/totp/setup") + public MfaSetupData setupTotp(@AuthenticationPrincipal Jwt jwt) { + return accountService.setupTotp(jwt.getSubject()); + } + + record TotpVerifyRequest(String secret, String code) {} + + @PostMapping("/mfa/totp/verify") + public Map verifyTotp(@AuthenticationPrincipal Jwt jwt, + @RequestBody TotpVerifyRequest request) { + boolean ok = accountService.verifyTotpCode(request.secret(), request.code()); + 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 removeTotp(@AuthenticationPrincipal Jwt jwt) { + accountService.removeMfa(jwt.getSubject()); + return ResponseEntity.noContent().build(); + } + + // --- Passkeys --- + + @GetMapping("/mfa/webauthn") + public List listPasskeys(@AuthenticationPrincipal Jwt jwt) { + return accountService.listPasskeys(jwt.getSubject()); + } + + @PatchMapping("/mfa/webauthn/{id}/name") + public ResponseEntity renamePasskey(@AuthenticationPrincipal Jwt jwt, + @PathVariable String id, + @RequestBody Map body) { + String name = body.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().build(); + } + accountService.renamePasskey(jwt.getSubject(), id, name.trim()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/mfa/webauthn/{id}") + public ResponseEntity deletePasskey(@AuthenticationPrincipal Jwt jwt, + @PathVariable String id) { + accountService.deletePasskey(jwt.getSubject(), id); + return ResponseEntity.noContent().build(); + } + + // --- MFA Preference --- + + @PostMapping("/mfa/method-preference") + public ResponseEntity setMfaPreference(@AuthenticationPrincipal Jwt jwt, + @RequestBody Map body) { + accountService.setMfaMethodPreference(jwt.getSubject(), body.get("preference")); + return ResponseEntity.noContent().build(); + } +} From ab240e42b09cc23a00db075f4f2b32d2b08394b3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:41:21 +0200 Subject: [PATCH 06/19] feat: add /api/account/** security config and MFA enforcement exemptions Permit /settings/** SPA route, gate /api/account/** as authenticated, and exempt account MFA/profile/password paths from MFA enforcement filter. --- .../net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java | 3 +++ .../java/net/siegeln/cameleer/saas/config/SecurityConfig.java | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java b/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java index 7e62c69..3b6cc07 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java @@ -26,6 +26,9 @@ public class MfaEnforcementFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class); private static final Set EXEMPT_PREFIXES = Set.of( "/api/tenant/mfa/", + "/api/account/mfa/", + "/api/account/profile", + "/api/account/password", "/api/config", "/api/me", "/api/onboarding", diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java index c40cacb..298ffc0 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java @@ -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() From cc3d2dc111bf61f70b7411c8b08ca2fa477107cf Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:44:20 +0200 Subject: [PATCH 07/19] refactor: delegate TenantPortalService MFA/password/passkey methods to AccountService Co-Authored-By: Claude Sonnet 4.6 --- .../saas/portal/TenantPortalService.java | 167 ++---------------- 1 file changed, 19 insertions(+), 148 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java index db3df94..ead647a 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java @@ -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 codes = List.of(); - if (response.containsKey("codes")) { - var rawCodes = response.get("codes"); - if (rawCodes instanceof List) { - codes = ((List) 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 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 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; - } } From 665ffefd3e3038b0f78f9ab5bdd87dbfda585dc3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:46:20 +0200 Subject: [PATCH 08/19] refactor: use AccountService for display name in OnboardingService Co-Authored-By: Claude Sonnet 4.6 --- .../saas/onboarding/OnboardingService.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java b/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java index fb65762..87d8a70 100644 --- a/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java +++ b/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java @@ -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); } } From 022b6d97228e1795c0f0f46fb6cbe364536c3b3b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:46:42 +0200 Subject: [PATCH 09/19] feat: add vendor admin management (list, create/invite, remove, reset password/MFA) Co-Authored-By: Claude Sonnet 4.6 --- .../saas/vendor/VendorAdminController.java | 53 ++++++++ .../saas/vendor/VendorAdminService.java | 119 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java new file mode 100644 index 0000000..f1b82d9 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java @@ -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 listAdmins() { + return vendorAdminService.listAdmins(); + } + + @PostMapping + public CreateAdminResponse createAdmin(@RequestBody CreateAdminRequest request) { + return vendorAdminService.createAdmin(request); + } + + @DeleteMapping("/{userId}") + public ResponseEntity removeAdmin(@AuthenticationPrincipal Jwt jwt, + @PathVariable String userId) { + vendorAdminService.removeAdmin(userId, jwt.getSubject()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{userId}/reset-password") + public ResponseEntity resetPassword(@PathVariable String userId, + @RequestBody Map body) { + vendorAdminService.resetAdminPassword(userId, body.get("password")); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{userId}/mfa") + public ResponseEntity resetMfa(@PathVariable String userId) { + vendorAdminService.resetAdminMfa(userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java new file mode 100644 index 0000000..7969390 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java @@ -0,0 +1,119 @@ +package net.siegeln.cameleer.saas.vendor; + +import net.siegeln.cameleer.saas.account.AccountService; +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; + +@Service +public class VendorAdminService { + + private static final Logger log = LoggerFactory.getLogger(VendorAdminService.class); + private static final String VENDOR_ROLE_NAME = "saas-vendor"; + + private final LogtoManagementClient logtoClient; + private final AccountService accountService; + private final EmailConnectorService emailConnectorService; + + public VendorAdminService(LogtoManagementClient logtoClient, + AccountService accountService, + EmailConnectorService emailConnectorService) { + this.logtoClient = logtoClient; + this.accountService = accountService; + this.emailConnectorService = emailConnectorService; + } + + // --- Records --- + + public record VendorAdmin(String userId, String name, String email) {} + public record CreateAdminRequest(String email, String tempPassword) {} + public record CreateAdminResponse(boolean invited, String tempPassword) {} + + // --- Methods --- + + private String getVendorRoleId() { + var role = logtoClient.getRoleByName(VENDOR_ROLE_NAME); + if (role == null) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "Vendor role '" + VENDOR_ROLE_NAME + "' not found in Logto"); + } + return String.valueOf(role.get("id")); + } + + public List listAdmins() { + String roleId = getVendorRoleId(); + var users = logtoClient.listRoleUsers(roleId); + return users.stream() + .map(u -> new VendorAdmin( + String.valueOf(u.get("id")), + u.get("name") != null ? String.valueOf(u.get("name")) : "", + u.get("primaryEmail") != null ? String.valueOf(u.get("primaryEmail")) : "" + )) + .toList(); + } + + public CreateAdminResponse createAdmin(CreateAdminRequest request) { + if (request.email() == null || request.email().isBlank() || !request.email().contains("@")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Valid email address required"); + } + + String roleId = getVendorRoleId(); + boolean emailConfigured = emailConnectorService.getEmailConnector() != null; + + String userId; + boolean invited; + String tempPassword = null; + + if (emailConfigured && (request.tempPassword() == null || request.tempPassword().isBlank())) { + // Invite via email — no org needed for vendor (global role 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) { + accountService.validatePassword(newPassword); + logtoClient.updateUserPassword(userId, newPassword); + log.info("Reset password for vendor admin {}", userId); + } + + public void resetAdminMfa(String userId) { + logtoClient.deleteAllMfaVerifications(userId); + log.info("Reset MFA for vendor admin {}", userId); + } +} From 0da1ffea7fbb9032cdef3d6f1e235e6f76c77245 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:48:00 +0200 Subject: [PATCH 10/19] fix: guard against null orgId in createAndInviteUser and createUserWithPassword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendor admins use global roles, not org roles — passing null orgId would previously cause addUserToOrganization to call /api/organizations/null/users and fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../saas/identity/LogtoManagementClient.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index 50386eb..0f0ba49 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -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; From bf42f13afce701b2cfd1ed93ec7db9c09ad9097c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:49:44 +0200 Subject: [PATCH 11/19] feat: add TypeScript types and React Query hooks for account and vendor admin APIs Co-Authored-By: Claude Sonnet 4.6 --- ui/src/api/account-hooks.ts | 100 +++++++++++++++++++++++++++++++ ui/src/api/vendor-admin-hooks.ts | 43 +++++++++++++ ui/src/types/api.ts | 24 ++++++++ 3 files changed, 167 insertions(+) create mode 100644 ui/src/api/account-hooks.ts create mode 100644 ui/src/api/vendor-admin-hooks.ts diff --git a/ui/src/api/account-hooks.ts b/ui/src/api/account-hooks.ts new file mode 100644 index 0000000..748ec01 --- /dev/null +++ b/ui/src/api/account-hooks.ts @@ -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({ + queryKey: ['account', 'profile'], + queryFn: () => api.get('/account/profile'), + }); +} + +export function useUpdateDisplayName() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name) => api.patch('/account/profile', { name }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'profile'] }), + }); +} + +// --- Password --- + +export function useChangePassword() { + return useMutation({ + mutationFn: (body) => api.post('/account/password', body), + }); +} + +// --- MFA --- + +export function useAccountMfaStatus() { + return useQuery({ + queryKey: ['account', 'mfa', 'status'], + queryFn: () => api.get('/account/mfa/status'), + }); +} + +export function useAccountMfaSetup() { + return useMutation({ + mutationFn: () => api.post('/account/mfa/totp/setup'), + }); +} + +export function useAccountMfaVerify() { + const qc = useQueryClient(); + return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({ + mutationFn: (body) => api.post('/account/mfa/totp/verify', body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +export function useAccountBackupCodes() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post('/account/mfa/backup-codes'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +export function useAccountMfaRemove() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.delete('/account/mfa/totp'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +// --- Passkeys --- + +export function useAccountPasskeyList() { + return useQuery({ + queryKey: ['account', 'mfa', 'webauthn'], + queryFn: () => api.get('/account/mfa/webauthn'), + }); +} + +export function useAccountRenamePasskey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, name }) => api.patch(`/account/mfa/webauthn/${id}/name`, { name }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +export function useAccountDeletePasskey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.delete(`/account/mfa/webauthn/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['account', 'mfa'] }), + }); +} + +// --- MFA Preference --- + +export function useAccountMfaPreference() { + return useMutation({ + mutationFn: (preference) => api.post('/account/mfa/method-preference', { preference }), + }); +} diff --git a/ui/src/api/vendor-admin-hooks.ts b/ui/src/api/vendor-admin-hooks.ts new file mode 100644 index 0000000..780e576 --- /dev/null +++ b/ui/src/api/vendor-admin-hooks.ts @@ -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({ + queryKey: ['vendor', 'admins'], + queryFn: () => api.get('/vendor/admins'), + }); +} + +export function useCreateVendorAdmin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req) => api.post('/vendor/admins', req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), + }); +} + +export function useRemoveVendorAdmin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId) => api.delete(`/vendor/admins/${userId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), + }); +} + +export function useResetVendorAdminPassword() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, password }) => + api.post(`/vendor/admins/${userId}/reset-password`, { password }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), + }); +} + +export function useResetVendorAdminMfa() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId) => api.delete(`/vendor/admins/${userId}/mfa`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'admins'] }), + }); +} diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index aeab2d2..f2488ef 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -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; +} From e563631efbcb8523be0f721862f21983dd8aa5f0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:53:05 +0200 Subject: [PATCH 12/19] feat: extract shared account components (Profile, Password, MFA, Passkey) Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/account/MfaSection.tsx | 259 ++++++++++++++++++ ui/src/components/account/PasskeySection.tsx | 136 +++++++++ .../account/PasswordChangeSection.tsx | 88 ++++++ ui/src/components/account/ProfileSection.tsx | 82 ++++++ 4 files changed, 565 insertions(+) create mode 100644 ui/src/components/account/MfaSection.tsx create mode 100644 ui/src/components/account/PasskeySection.tsx create mode 100644 ui/src/components/account/PasswordChangeSection.tsx create mode 100644 ui/src/components/account/ProfileSection.tsx diff --git a/ui/src/components/account/MfaSection.tsx b/ui/src/components/account/MfaSection.tsx new file mode 100644 index 0000000..fb50592 --- /dev/null +++ b/ui/src/components/account/MfaSection.tsx @@ -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(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 ( + +
+ +
+
+ ); + } + + // Backup codes display + if (codes) { + return ( + + + 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. + +
+ {codes.map((code) => ( + {code} + ))} +
+
+ + +
+ +
+ +
+
+ ); + } + + // Setup flow — QR code + verification + if (setupData) { + return ( + +

+ Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below. +

+
+ +
+
+ {setupData.secret} +
+
+ + setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="Enter 6-digit code" + required + autoComplete="one-time-code" + /> + +
+ + +
+
+
+ ); + } + + // Main view — enrolled or not + return ( + +
+ Status: + {mfaStatus?.enrolled ? ( + + ) : ( + + )} +
+ {mfaStatus?.enrolled ? ( + <> +

+ Your account is protected with a TOTP authenticator app. +

+
+ + {confirmRemove ? ( +
+ + + +
+ ) : ( + + )} +
+ + ) : ( + <> +

+ Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app. +

+
+ +
+ + )} +
+ ); +} diff --git a/ui/src/components/account/PasskeySection.tsx b/ui/src/components/account/PasskeySection.tsx new file mode 100644 index 0000000..2c16441 --- /dev/null +++ b/ui/src/components/account/PasskeySection.tsx @@ -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 ( + +

+ Use your fingerprint, face, or security key instead of typing a code every time. +

+ +
+ ); +} + +export function PasskeySection() { + const { toast } = useToast(); + const { data: passkeys, isLoading } = useAccountPasskeyList(); + const renamePasskey = useAccountRenamePasskey(); + const deletePasskey = useAccountDeletePasskey(); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [confirmDeleteId, setConfirmDeleteId] = useState(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 ( + +

+ Use your fingerprint, face, or security key to sign in faster. +

+ {credentials.length === 0 ? ( +

+ No passkeys registered. Passkeys can be registered during sign-in when prompted. +

+ ) : ( +
+ {credentials.map((pk) => ( +
+
+ {editingId === pk.id ? ( +
+ setEditName(e.target.value)} placeholder="Passkey name" style={{ maxWidth: 200 }} /> + + +
+ ) : ( + <> +
{pk.name || 'Unnamed passkey'}
+
+ {parseAgent(pk.agent)} · Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'} +
+ + )} +
+ {editingId !== pk.id && ( +
+ + {confirmDeleteId === pk.id ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/account/PasswordChangeSection.tsx b/ui/src/components/account/PasswordChangeSection.tsx new file mode 100644 index 0000000..622bf21 --- /dev/null +++ b/ui/src/components/account/PasswordChangeSection.tsx @@ -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 ( + +

+ Update your login password. Minimum 8 characters. +

+
+ + setCurrentPassword(e.target.value)} + placeholder="Enter current password" + required + /> + + + setNewPassword(e.target.value)} + placeholder="Enter new password" + required + minLength={8} + /> + + + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + required + minLength={8} + /> + +
+ +
+
+
+ ); +} diff --git a/ui/src/components/account/ProfileSection.tsx b/ui/src/components/account/ProfileSection.tsx new file mode 100644 index 0000000..a354602 --- /dev/null +++ b/ui/src/components/account/ProfileSection.tsx @@ -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 ( + +
+ +
+
+ ); + } + + return ( + +
+ + + + + setName(e.target.value)} + placeholder="Enter display name" + /> + +
+ +
+
+
+ ); +} From 5d1d263c7477b988eb80abd299b4d545db8b6571 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:54:26 +0200 Subject: [PATCH 13/19] feat: add AccountSettingsPage composing shared account components --- ui/src/pages/AccountSettingsPage.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 ui/src/pages/AccountSettingsPage.tsx diff --git a/ui/src/pages/AccountSettingsPage.tsx b/ui/src/pages/AccountSettingsPage.tsx new file mode 100644 index 0000000..e217a4a --- /dev/null +++ b/ui/src/pages/AccountSettingsPage.tsx @@ -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 ( +
+
+ +

Account Settings

+
+ + + + + +
+ ); +} From d44ee4b977ee1dfc17a27ebbe02b00c72f600121 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:56:03 +0200 Subject: [PATCH 14/19] feat: add VendorAdminsPage with list, create/invite, remove, reset actions Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/Layout.tsx | 11 +- ui/src/pages/vendor/VendorAdminsPage.tsx | 399 +++++++++++++++++++++++ ui/src/router.tsx | 6 + 3 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/vendor/VendorAdminsPage.tsx diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a9235df..dde1b0e 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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'; @@ -157,6 +157,15 @@ export function Layout() { Auth Policy +
navigate('/vendor/admins')} + > + + Administrators +
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} diff --git a/ui/src/pages/vendor/VendorAdminsPage.tsx b/ui/src/pages/vendor/VendorAdminsPage.tsx new file mode 100644 index 0000000..291f699 --- /dev/null +++ b/ui/src/pages/vendor/VendorAdminsPage.tsx @@ -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(null); + + // Reset password state + const [pwTarget, setPwTarget] = useState(null); + const [pwValue, setPwValue] = useState(''); + + // Reset MFA state + const [mfaTarget, setMfaTarget] = useState(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[] = [ + { + key: 'name', + header: 'Name', + render: (_v, row) => ( + + {row.name} + {row.id === currentUserId && ( + + )} + + ), + }, + { + key: 'email', + header: 'Email', + render: (_v, row) => ( + {row.email} + ), + }, + { + key: 'id', + header: 'Actions', + render: (_v, row) => ( +
+ + + +
+ ), + }, + ]; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ + Could not fetch administrator list. Please refresh. + +
+ ); + } + + return ( +
+ {/* Header */} +
+

Platform Administrators

+ +
+ + {/* Add admin form */} + {showAdd && !createdCredentials && ( + + {emailLoading ? ( +
+ +
+ ) : ( +
+ {emailConfigured ? ( + + An invitation email will be sent to the new administrator. + + ) : ( + + Email connector is not configured. The new administrator will receive a temporary + password instead of an invitation link. + + )} + + + setAddEmail(e.target.value)} + required + /> + + + {!emailConfigured && ( + + setAddTempPassword(e.target.value)} + /> + + )} + +
+ + +
+
+ )} +
+ )} + + {/* Credential display after creation (no email connector) */} + {showAdd && createdCredentials && ( + + + Account created. Share these credentials securely — the temporary password will not be + shown again. + +
+
+ Email + {createdCredentials.email} +
+
+ Temporary password +
+ + {createdCredentials.tempPassword} + + +
+
+
+
+ +
+
+ )} + + {/* Admin table */} + {admins.length === 0 ? ( + } + title="No platform administrators" + description="Add an administrator to grant platform:admin access." + action={ + + } + /> + ) : ( + + )} + + {/* Remove confirmation dialog */} + 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 && ( + + + This will remove all MFA factors for this administrator. They will need to re-enroll if + MFA is required. + +
+ + +
+
+ )} + + {/* Reset password inline card */} + {pwTarget && ( + +
{ + 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 }} + > + + setPwValue(e.target.value)} + required + minLength={8} + /> + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 0dd6359..b585ffc 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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'; @@ -120,6 +121,11 @@ export function AppRouter() { } /> + }> + + + } /> {/* Tenant portal */} } /> From 8668642b8d376b273b430ce19acd16699992f435 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:58:27 +0200 Subject: [PATCH 15/19] feat: add account settings route and user menu dropdown Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/Layout.tsx | 9 +++++++++ ui/src/router.tsx | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index dde1b0e..ebb9026 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -55,6 +55,14 @@ export function Layout() { return { label }; }); + const userMenuItems = [ + { + label: 'Account Settings', + icon: , + onClick: () => navigate('/settings/account'), + }, + ]; + const sidebar = ( {}}> diff --git a/ui/src/router.tsx b/ui/src/router.tsx index b585ffc..4cf3191 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -27,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(); @@ -135,6 +136,9 @@ export function AppRouter() { } /> } /> + {/* Account settings — accessible to any authenticated user */} + } /> + {/* Default redirect — vendor goes to /vendor/tenants, customer to /tenant */} } /> From e5e0cad7c3473e76d04ae2b83928d32b8784101f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:59:09 +0200 Subject: [PATCH 16/19] refactor: consolidate tenant SettingsPage to use shared account components Co-Authored-By: Claude Sonnet 4.6 --- ui/src/api/tenant-hooks.ts | 115 ++------ ui/src/pages/tenant/SettingsPage.tsx | 426 +-------------------------- 2 files changed, 37 insertions(+), 504 deletions(-) diff --git a/ui/src/api/tenant-hooks.ts b/ui/src/api/tenant-hooks.ts index c4dd3a5..d5ea2fe 100644 --- a/ui/src/api/tenant-hooks.ts +++ b/ui/src/api/tenant-hooks.ts @@ -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({ @@ -121,6 +134,14 @@ export function useResetTeamMemberPassword() { }); } +export function useResetTeamMemberMfa() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), + }); +} + export function useTenantSettings() { return useQuery({ queryKey: ['tenant', 'settings'], @@ -128,6 +149,14 @@ export function useTenantSettings() { }); } +export function useUpdateTenantSettings() { + const qc = useQueryClient(); + return useMutation>({ + mutationFn: (updates) => api.patch('/tenant/settings', updates), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }), + }); +} + export function useTenantAuditLog(filters: Omit) { const params = new URLSearchParams(); if (filters.action) params.set('action', filters.action); @@ -144,90 +173,6 @@ export function useTenantAuditLog(filters: Omit) { }); } -// MFA hooks -export function useMfaStatus() { - return useQuery({ - queryKey: ['tenant', 'mfa', 'status'], - queryFn: () => api.get('/tenant/mfa/status'), - }); -} - -export function useMfaSetup() { - return useMutation({ - 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({ - mutationFn: () => api.post('/tenant/mfa/backup-codes'), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), - }); -} - -export function useMfaRemove() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: () => api.delete('/tenant/mfa/totp'), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), - }); -} - -export function useResetTeamMemberMfa() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), - }); -} - -export function useUpdateTenantSettings() { - const qc = useQueryClient(); - return useMutation>({ - mutationFn: (updates) => api.patch('/tenant/settings', updates), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }), - }); -} - -// Passkey hooks -export function usePasskeyList() { - return useQuery({ - queryKey: ['tenant', 'mfa', 'webauthn'], - queryFn: () => api.get('/tenant/mfa/webauthn'), - }); -} - -export function useRenamePasskey() { - const qc = useQueryClient(); - return useMutation({ - 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({ - mutationFn: (id) => api.delete(`/tenant/mfa/webauthn/${id}`), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), - }); -} - -export function useUpdateMfaMethodPreference() { - return useMutation({ - mutationFn: (preference) => api.post('/tenant/mfa/method-preference', { preference }), - }); -} - // Auth settings hooks export function useTenantAuthSettings() { return useQuery({ diff --git a/ui/src/pages/tenant/SettingsPage.tsx b/ui/src/pages/tenant/SettingsPage.tsx index 842881b..3a22e7c 100644 --- a/ui/src/pages/tenant/SettingsPage.tsx +++ b/ui/src/pages/tenant/SettingsPage.tsx @@ -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(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 ( - -
- -
-
- ); - } - - // Backup codes display - if (codes) { - return ( - - - 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. - -
- {codes.map((code) => ( - {code} - ))} -
-
- - -
- -
- -
-
- ); - } - - // Setup flow — QR code + verification - if (setupData) { - return ( - -

- Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below. -

-
- -
-
- {setupData.secret} -
-
- - setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))} - placeholder="Enter 6-digit code" - required - autoComplete="one-time-code" - /> - -
- - -
-
-
- ); - } - - // Main view — enrolled or not - return ( - -
- Status: - {mfaStatus?.enrolled ? ( - - ) : ( - - )} -
- {mfaStatus?.enrolled ? ( - <> -

- Your account is protected with a TOTP authenticator app. -

-
- - {confirmRemove ? ( -
- - - -
- ) : ( - - )} -
- - ) : ( - <> -

- Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app. -

-
- -
- - )} -
- ); -} - 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 ( - -

- Use your fingerprint, face, or security key instead of typing a code every time. -

- -
- ); -} - -function PasskeySection() { - const { toast } = useToast(); - const { data: passkeys, isLoading } = usePasskeyList(); - const renamePasskey = useRenamePasskey(); - const deletePasskey = useDeletePasskey(); - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [confirmDeleteId, setConfirmDeleteId] = useState(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 ( - -

- Use your fingerprint, face, or security key to sign in faster. -

- {credentials.length === 0 ? ( -

- No passkeys registered. Passkeys can be registered during sign-in when prompted. -

- ) : ( -
- {credentials.map((pk) => ( -
-
- {editingId === pk.id ? ( -
- setEditName(e.target.value)} placeholder="Passkey name" style={{ maxWidth: 200 }} /> - - -
- ) : ( - <> -
{pk.name || 'Unnamed passkey'}
-
- {parseAgent(pk.agent)} · Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'} -
- - )} -
- {editingId !== pk.id && ( -
- - {confirmDeleteId === pk.id ? ( - <> - - - - ) : ( - - )} -
- )} -
- ))} -
- )} -
- ); -} - 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 (
@@ -628,40 +249,7 @@ export function SettingsPage() {

- -

- Update your login password. Minimum 8 characters. -

-
- - setNewPassword(e.target.value)} - placeholder="Enter new password" - required - minLength={8} - /> - - - setConfirmPassword(e.target.value)} - placeholder="Confirm new password" - required - minLength={8} - /> - -
- -
-
-
+

From 372d3c77a024cedce0ccc01139a9b12a51cf96e0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:06:16 +0200 Subject: [PATCH 17/19] =?UTF-8?q?fix:=20code=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20dead=20catch=20blocks,=20notification=20email,=20ro?= =?UTF-8?q?le=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead IllegalArgumentException catch blocks in TenantPortalController (delegated methods now throw ResponseStatusException, handled by Spring) - Add password reset notification email in VendorAdminService.resetAdminPassword - Add verifyIsVendorAdmin guard to resetAdminPassword and resetAdminMfa to prevent platform admins from resetting arbitrary non-admin users Co-Authored-By: Claude Opus 4.6 (1M context) --- .../saas/portal/TenantPortalController.java | 24 ++++----------- .../saas/vendor/VendorAdminService.java | 29 ++++++++++++++++++- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java index 6cbafb6..91a7a4e 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java @@ -107,12 +107,8 @@ public class TenantPortalController { @PostMapping("/password") public ResponseEntity 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 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") diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java index 7969390..30fa455 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java @@ -2,6 +2,7 @@ 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; @@ -20,13 +21,16 @@ public class VendorAdminService { private final LogtoManagementClient logtoClient; private final AccountService accountService; private final EmailConnectorService emailConnectorService; + private final PasswordResetNotificationService passwordNotificationService; public VendorAdminService(LogtoManagementClient logtoClient, AccountService accountService, - EmailConnectorService emailConnectorService) { + EmailConnectorService emailConnectorService, + PasswordResetNotificationService passwordNotificationService) { this.logtoClient = logtoClient; this.accountService = accountService; this.emailConnectorService = emailConnectorService; + this.passwordNotificationService = passwordNotificationService; } // --- Records --- @@ -107,13 +111,36 @@ public class VendorAdminService { } 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"); + } + } } From e9e18f6c380ad8b6b7f24f6c02d98dadf67026a7 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:09:41 +0200 Subject: [PATCH 18/19] docs: update CLAUDE.md for account package, vendor admins, and shared components - Add account/ package to Key Packages table - Add VendorAdminService/Controller to vendor/ package - Note TenantPortalService delegation to AccountService - Update ui/CLAUDE.md: AccountSettingsPage, VendorAdminsPage, Administrators sidebar, user menu dropdown, shared components Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 17 +++++++++-------- ui/CLAUDE.md | 7 ++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d6baa7f..0c83395 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 — 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: ""})` — find execution flows related to the issue 2. `gitnexus_context({name: ""})` — 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 diff --git a/ui/CLAUDE.md b/ui/CLAUDE.md index 5206c45..bc987b8 100644 --- a/ui/CLAUDE.md +++ b/ui/CLAUDE.md @@ -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/`) From f823a409d0effcfcbad62fb31239d5d50ce3aa35 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:15:19 +0200 Subject: [PATCH 19/19] fix: add AccountService mock to TenantPortalServiceTest constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TenantPortalService constructor gained an AccountService parameter in the consolidation refactor — the test was missing it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cameleer/saas/portal/TenantPortalServiceTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java index fe28843..17434e3 100644 --- a/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java @@ -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