22 Commits

Author SHA1 Message Date
hsiegeln
06134d6e67 fix: TOTP label includes org name, passkeys show device as default name
All checks were successful
CI / build (push) Successful in 2m10s
CI / docker (push) Successful in 1m27s
- TOTP otpauth URI issuer changed from "Cameleer" to "Cameleer - <org>"
  so authenticator apps display the organization name
- Passkeys without a custom name now show parsed device info (e.g.
  "Chrome on Windows") instead of "Unnamed passkey"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:53:05 +02:00
hsiegeln
7fe9c581b0 fix: remove MFA card from tenant settings, constrain card widths
All checks were successful
CI / build (push) Successful in 2m10s
CI / docker (push) Successful in 1m25s
MFA enrollment now happens during sign-in. Tenant settings page reduced
to: Tenant Details + Auth Policy side-by-side (max 520px each), Passkeys
full-width below.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:45:31 +02:00
hsiegeln
7fc8a4d407 fix: team invite role resolution, user cleanup, and settings page redesign
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m33s
- Resolve org role names to Logto role IDs in invite and role change flows
  (fixes entity.relation_foreign_key_not_found on invite)
- Handle existing Logto users on re-invite instead of failing with
  email_already_in_use
- Delete users from Logto when removed from last org membership
- Consolidate tenant settings page into 3 cards: Tenant Details, MFA,
  Authentication Policy — remove duplicate MFA Enforcement and Change
  Password (now in Account Settings)
- Make passkey list scrollable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:36:21 +02:00
hsiegeln
e21a9d6046 fix: override WebAuthn type in authentication verify too
All checks were successful
CI / build (push) Successful in 1m57s
CI / docker (push) Successful in 1m30s
Same fix as registration verify — @simplewebauthn/browser returns
type: "public-key" but Logto expects type: "WebAuthn".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 20:57:37 +02:00
hsiegeln
0481cefaf4 fix: sign-in MFA flow overhaul — passkey verify, backup codes, defaults
All checks were successful
CI / build (push) Successful in 2m19s
CI / docker (push) Successful in 1m4s
Four fixes for the MFA sign-in flow:

1. Fix passkey verify crash: extract authenticationOptions from Logto
   response (was passing full response as optionsJSON). Pass
   verificationId to the verify endpoint.

2. Default to passkey verification when no MFA method preference is
   stored (was showing method picker which offered TOTP to passkey-only
   users).

3. Show backup codes after MFA enrollment: new mfaEnrollBackupCodes
   mode with copy/download buttons and confirmation checkbox. Users
   must save codes before completing sign-in.

4. Remove duplicate error alerts in enrollment screens (top-level
   alert handles all modes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 20:49:32 +02:00
hsiegeln
040ae60be5 fix: correct Experience API endpoints for TOTP and backup codes
All checks were successful
CI / build (push) Successful in 2m4s
CI / docker (push) Successful in 1m1s
- TOTP secret: /verification/totp/secret (not /verification/totp)
- Backup codes: generate via /verification/backup-code/generate first,
  then bind with the returned verificationId. Cannot bind BackupCode
  without generating codes first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:30:54 +02:00
hsiegeln
d8f7452ab7 feat: full MFA enrollment during sign-in — passkey + TOTP + backup codes
All checks were successful
CI / build (push) Successful in 2m2s
CI / docker (push) Successful in 1m10s
- Bind BackupCode after primary MFA factor (WebAuthn or TOTP) to satisfy
  Logto's requirement that backup codes accompany any MFA method.
- Add TOTP enrollment option alongside passkey on the enrollment screen:
  "Use passkey" / "Use authenticator app" / "Set up later".
- TOTP enrollment shows QR code + secret + 6-digit verification inline
  in the sign-in UI, using Experience API endpoints.
- Added createTotpSecret() and verifyTotpSetup() to experience-api.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:22:53 +02:00
hsiegeln
c4fe16048c fix: include WebAuthn in bootstrap MFA factors
All checks were successful
CI / build (push) Successful in 2m14s
CI / docker (push) Successful in 25s
Bootstrap only set [Totp, BackupCode] — WebAuthn was missing. Now
matches LogtoStartupConfig: all three factors available from first boot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:04:47 +02:00
hsiegeln
cba420fbeb fix: always offer MFA+passkey enrollment, separate availability from enforcement
All checks were successful
CI / build (push) Successful in 2m19s
CI / docker (push) Successful in 1m43s
Two fundamental fixes:

- user.missing_mfa now triggers MfaEnrollmentError (enroll UI) instead
  of MfaRequiredError (verify UI). Users without MFA were shown a TOTP
  code prompt they couldn't fill.
- Logto MFA factors always set to [Totp, WebAuthn, BackupCode] with
  UserControlled policy on startup. Availability is always-on for all
  users. The vendor auth policy controls enforcement (via
  MfaEnforcementFilter), not what Logto offers during sign-in.
- Removed syncMfaConfigToLogto from VendorAuthPolicyController — vendor
  policy changes no longer modify Logto's sign-in experience.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:59:21 +02:00
hsiegeln
67ec409383 fix: null display name, settings scrollbar, redundant passkey offer
All checks were successful
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 1m36s
- Profile API returns empty string instead of "null" when Logto user
  has no display name set (String.valueOf(null) → "null" bug).
- SettingsPage: add overflowY auto + flex 1 so content scrolls within
  the AppShell layout (which uses overflow: hidden).
- Remove redundant passkey offer from onboarding page — passkey
  enrollment now happens during sign-in via the Experience API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:53:13 +02:00
hsiegeln
3384510f3c fix: passkeys work independently of MFA mode
All checks were successful
CI / build (push) Successful in 2m15s
CI / docker (push) Successful in 1m1s
When MFA mode is off but passkeys are enabled, WebAuthn + BackupCode
factors are still synced to Logto. Previously, MFA off cleared all
factors including WebAuthn, so passkey enrollment was never offered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:45:30 +02:00
hsiegeln
18e6f32f90 refactor: move passkey enrollment to sign-in UI via Experience API
All checks were successful
CI / build (push) Successful in 2m12s
CI / docker (push) Successful in 1m49s
Remove the SaaS backend proxy approach for passkey registration (Account
API binding, Management API proxy, password modal in PasskeySection).
Instead, offer passkey enrollment natively during sign-in via Logto's
Experience API — the correct architectural layer.

Sign-in flow: when Logto returns MFA enrollment available (422), show a
"Secure your account" screen with Register passkey / Set up later. Uses
Experience API WebAuthn registration endpoints. Works for all users
(SaaS and future server users) since the sign-in UI is shared.

PasskeySection in account settings now only manages existing passkeys
(list/rename/delete) and directs users to register during sign-in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:33:46 +02:00
hsiegeln
4df6fc9e03 fix: proxy passkey bind through Management API
All checks were successful
CI / build (push) Successful in 2m17s
CI / docker (push) Successful in 1m29s
Logto's /api/my-account/ endpoints reject the opaque access token with
401 even though /api/verifications/ accepts it. The bind step now goes
through the SaaS backend which calls the Management API instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:23:41 +02:00
hsiegeln
2aa5100530 fix: add password re-verification before passkey registration
All checks were successful
CI / build (push) Successful in 2m28s
CI / docker (push) Successful in 1m32s
Logto Account API requires identity verification (logto-verification-id
header) for sensitive MFA operations. Adds a password prompt modal before
the WebAuthn ceremony — verifies password first, then proceeds with
passkey registration using the verification record ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:10:47 +02:00
hsiegeln
c360d9ad5f fix: override WebAuthn credential type for Logto Account API
All checks were successful
CI / build (push) Successful in 3m29s
CI / docker (push) Successful in 2m14s
@simplewebauthn/browser returns type: "public-key" (W3C standard) but
Logto's verify endpoint expects type: "WebAuthn".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:59:53 +02:00
hsiegeln
e7952dd9de fix: keep vendor sidebar active on account settings page
All checks were successful
CI / build (push) Successful in 1m54s
CI / docker (push) Successful in 1m26s
Vendor sidebar collapsed and tenant sidebar appeared when navigating to
/settings/account because onVendorRoute was false for non-/vendor paths.
Now vendor users stay on vendor sidebar for all routes except /tenant/*.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:18:09 +02:00
hsiegeln
687598952f fix: correct Account Center enablement — mfa field is a string enum
All checks were successful
CI / build (push) Successful in 2m6s
CI / docker (push) Successful in 1m6s
Logto's PATCH /api/account-center expects mfa as 'Off'|'ReadOnly'|'Edit',
not a nested object. Fixes 400 Bad Request on startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:14:31 +02:00
hsiegeln
c22580e124 feat: always enable WebAuthn in MFA factors and add passkey registration
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m26s
- Sync vendor auth policy to Logto sign-in experience on save and on
  startup. Always include WebAuthn + TOTP + BackupCode in MFA factors
  when MFA is enabled — no reason to gate passkeys behind a toggle.
- Enable Logto Account Center on startup for user-facing MFA management.
- Add passkey registration to account settings via Logto Account API.
  Frontend calls Logto directly (same domain) for the WebAuthn ceremony:
  generate options, browser credential creation, verify, and bind.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:01:58 +02:00
hsiegeln
a5c20830a7 fix: prevent MFA lockout and move enrollment to modal dialog
All checks were successful
CI / build (push) Successful in 1m58s
CI / docker (push) Successful in 1m47s
Three fixes for MFA enrollment and sign-in:

- Defer TOTP registration with Logto until after 6-digit code verification.
  Previously setupTotp() immediately registered the secret, so abandoning
  enrollment mid-way left MFA active without a working authenticator.
- Move entire MFA enrollment flow (QR code, verify, backup codes) into a
  Modal dialog instead of replacing the Card content inline.
- Fix sign-in MFA flow: submitMfa() no longer calls identifyUser() after
  TOTP verify — user is already identified, and passing the MFA
  verificationId to identification returned 422 ("method not activated").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:25:15 +02:00
hsiegeln
9231a1fc60 fix: move forgot password link below sign-in button
All checks were successful
CI / build (push) Successful in 2m0s
CI / docker (push) Successful in 1m7s
Repositions the "Forgot password?" link from above the sign-in button
to below it, matching the desired layout. Updates link style to be
centered with link color instead of right-aligned muted text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:06:36 +02:00
f325416833 Merge pull request 'feature/vendor-admin-account-settings' (#60) from feature/vendor-admin-account-settings into main
All checks were successful
CI / build (push) Successful in 3m22s
CI / docker (push) Successful in 27s
Reviewed-on: #60
2026-04-27 15:58:05 +02:00
0b4d0e3b2f Merge pull request 'feat: vendor admin management and shared account settings' (#59) from feature/vendor-admin-account-settings into main
All checks were successful
CI / build (push) Successful in 2m15s
CI / docker (push) Successful in 20s
Reviewed-on: #59
2026-04-27 15:20:23 +02:00
15 changed files with 718 additions and 417 deletions

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-saas** (3336 symbols, 7094 relationships, 281 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-saas** (3458 symbols, 7429 relationships, 292 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.

View File

@@ -616,7 +616,7 @@ api_patch "/api/sign-in-exp" '{
]
},
"mfa": {
"factors": ["Totp", "BackupCode"],
"factors": ["Totp", "WebAuthn", "BackupCode"],
"policy": "UserControlled"
}
}' >/dev/null 2>&1

View File

@@ -62,7 +62,7 @@ public class AccountController {
@PostMapping("/mfa/totp/verify")
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
@RequestBody TotpVerifyRequest request) {
boolean ok = accountService.verifyTotpCode(request.secret(), request.code());
boolean ok = accountService.verifyAndEnableTotp(jwt.getSubject(), request.secret(), request.code());
return Map.of("verified", ok);
}

View File

@@ -46,10 +46,12 @@ public class AccountService {
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
}
Object nameVal = user.get("name");
Object emailVal = user.get("primaryEmail");
return new ProfileData(
userId,
String.valueOf(user.getOrDefault("name", "")),
String.valueOf(user.getOrDefault("primaryEmail", ""))
nameVal != null ? String.valueOf(nameVal) : "",
emailVal != null ? String.valueOf(emailVal) : ""
);
}
@@ -108,11 +110,32 @@ public class AccountService {
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);
// Build otpauth URI locally — do NOT register with Logto yet.
// The secret is only registered after the user verifies the 6-digit code.
var user = logtoClient.getUser(userId);
String email = user != null ? String.valueOf(user.getOrDefault("primaryEmail", "")) : "";
String account = email.isBlank() ? userId : email;
// Include org name in issuer so authenticator apps show "Cameleer - OrgName"
String issuer = "Cameleer";
var orgs = logtoClient.getUserOrganizations(userId);
if (!orgs.isEmpty()) {
issuer = "Cameleer - " + orgs.getFirst().get("name");
}
String encodedIssuer = java.net.URLEncoder.encode(issuer, java.nio.charset.StandardCharsets.UTF_8);
String encodedAccount = java.net.URLEncoder.encode(account, java.nio.charset.StandardCharsets.UTF_8);
String otpauthUri = String.format(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
encodedIssuer, encodedAccount, secret, encodedIssuer);
return new MfaSetupData(secret, otpauthUri);
}
public boolean verifyAndEnableTotp(String userId, String secret, String code) {
if (!verifyTotpCode(secret, code)) return false;
logtoClient.createTotpVerification(userId, secret);
return true;
}
public boolean verifyTotpCode(String secret, String code) {

View File

@@ -0,0 +1,40 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* Ensures Logto sign-in experience always offers TOTP + WebAuthn + BackupCode
* on startup. Availability is always-on; enforcement is handled separately by
* MfaEnforcementFilter based on the vendor auth policy.
*/
@Component
public class LogtoStartupConfig {
private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
private final LogtoManagementClient logtoClient;
public LogtoStartupConfig(LogtoManagementClient logtoClient) {
this.logtoClient = logtoClient;
}
@EventListener(ApplicationReadyEvent.class)
public void onStartup() {
try {
List<String> factors = List.of("Totp", "WebAuthn", "BackupCode");
logtoClient.updateSignInExperience(Map.of(
"mfa", Map.of("factors", factors, "policy", "UserControlled")));
log.info("Logto MFA factors set to {} (UserControlled)", factors);
} catch (Exception e) {
log.warn("Failed to sync MFA factors on startup: {}", e.getMessage());
}
}
}

View File

@@ -209,19 +209,67 @@ public class LogtoManagementClient {
}
}
/** Create a user in Logto and add to organization with role. */
/** Delete a user from Logto entirely. */
public void deleteUser(String userId) {
if (!isAvailable() || userId == null) return;
try {
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
log.info("Deleted user {} from Logto", userId);
} catch (Exception e) {
log.warn("Failed to delete user {}: {}", userId, e.getMessage());
}
}
/** Find a user by email. Returns user map or null if not found. */
@SuppressWarnings("unchecked")
public Map<String, Object> findUserByEmail(String email) {
if (!isAvailable() || email == null) return null;
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/users?search="
+ java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8)
+ "&search.primaryEmail="
+ java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8)
+ "&page_size=5")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
if (resp == null) return null;
return ((List<Map<String, Object>>) resp).stream()
.filter(u -> email.equalsIgnoreCase(String.valueOf(u.get("primaryEmail"))))
.findFirst()
.orElse(null);
} catch (Exception e) {
log.warn("Failed to find user by email: {}", e.getMessage());
return null;
}
}
/** Create a user in Logto (or find existing by email) and add to organization with role. */
@SuppressWarnings("unchecked")
public String createAndInviteUser(String email, String orgId, String roleId) {
if (!isAvailable()) return null;
try {
var userResp = (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("primaryEmail", email, "name", email.split("@")[0]))
.retrieve()
.body(Map.class);
String userId = String.valueOf(userResp.get("id"));
String userId;
// Check if user already exists in Logto
var existing = findUserByEmail(email);
if (existing != null) {
userId = String.valueOf(existing.get("id"));
log.info("User '{}' already exists in Logto ({}), adding to org", email, userId);
} else {
var userResp = (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("primaryEmail", email, "name", email.split("@")[0]))
.retrieve()
.body(Map.class);
userId = String.valueOf(userResp.get("id"));
}
if (orgId != null) {
addUserToOrganization(orgId, userId);
if (roleId != null) {

View File

@@ -181,13 +181,14 @@ public class TenantPortalService {
return logtoClient.listOrganizationMembers(orgId);
}
public String inviteTeamMember(String email, String roleId) {
public String inviteTeamMember(String email, String roleName) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
return logtoClient.createAndInviteUser(email, orgId, roleId);
String resolvedRoleId = resolveOrgRoleId(roleName);
return logtoClient.createAndInviteUser(email, orgId, resolvedRoleId);
}
public void removeTeamMember(String userId) {
@@ -197,15 +198,33 @@ public class TenantPortalService {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
logtoClient.removeUserFromOrganization(orgId, userId);
// If the user has no remaining org memberships, delete from Logto entirely
var remainingOrgs = logtoClient.getUserOrganizations(userId);
if (remainingOrgs.isEmpty()) {
log.info("User {} has no remaining org memberships — deleting from Logto", userId);
logtoClient.deleteUser(userId);
}
}
public void changeTeamMemberRole(String userId, String roleId) {
public void changeTeamMemberRole(String userId, String roleName) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
logtoClient.assignOrganizationRole(orgId, userId, roleId);
String resolvedRoleId = resolveOrgRoleId(roleName);
logtoClient.assignOrganizationRole(orgId, userId, resolvedRoleId);
}
/** Resolve a role name (e.g. "viewer") to a Logto organization role ID. */
private String resolveOrgRoleId(String roleName) {
if (roleName == null || roleName.isBlank()) return null;
String resolved = logtoClient.findOrgRoleIdByName(roleName);
if (resolved == null) {
throw new IllegalArgumentException("Unknown organization role: " + roleName);
}
return resolved;
}
public void resetServerAdminPassword(String newPassword) {

View File

@@ -100,13 +100,12 @@
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
color: var(--text-link, #C6820E);
font-size: 13px;
padding: 0;
text-decoration: underline;
text-align: right;
align-self: flex-end;
margin-top: -8px;
text-decoration: none;
text-align: center;
align-self: center;
}
.forgotLink:hover {

View File

@@ -2,17 +2,20 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
import { startAuthentication } from '@simplewebauthn/browser';
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
import {
signIn, startRegistration, completeRegistration,
startForgotPassword, forgotPasswordVerifyAndReset,
verifyTotp, verifyBackupCode, submitMfa,
startWebAuthnAuth, verifyWebAuthnAuth,
MfaRequiredError,
startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile,
generateBackupCodes, createTotpSecret, verifyTotpSetup,
skipMfaEnrollment, submitInteraction,
MfaRequiredError, MfaEnrollmentError,
} from './experience-api';
import styles from './SignInPage.module.css';
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker';
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp' | 'mfaEnrollBackupCodes';
const SIGN_IN_SUBTITLES = [
"Prove you're not a mirage",
@@ -87,6 +90,8 @@ export function SignInPage() {
const [confirmNewPassword, setConfirmNewPassword] = useState('');
const [webauthnError, setWebauthnError] = useState('');
const [webauthnLoading, setWebauthnLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [backupCodesSaved, setBackupCodesSaved] = useState(false);
// Fetch sign-in experience to check if registration is enabled
useEffect(() => {
@@ -127,16 +132,19 @@ export function SignInPage() {
} catch (err) {
if (err instanceof MfaRequiredError) {
const pref = localStorage.getItem('mfa_method_preference');
if (pref === 'webauthn') {
setMode('mfaWebauthn');
} else if (pref === 'totp') {
if (pref === 'totp') {
setMode('mfaVerify');
} else {
setMode('mfaMethodPicker');
setMode('mfaWebauthn');
}
setLoading(false);
return;
}
if (err instanceof MfaEnrollmentError) {
setMode('mfaEnroll');
setLoading(false);
return;
}
setError(err instanceof Error ? err.message : 'Sign-in failed');
setLoading(false);
}
@@ -179,6 +187,11 @@ export function SignInPage() {
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
window.location.replace(redirectTo);
} catch (err) {
if (err instanceof MfaEnrollmentError) {
setMode('mfaEnroll');
setLoading(false);
return;
}
setError(err instanceof Error ? err.message : 'Verification failed');
setLoading(false);
}
@@ -258,13 +271,17 @@ export function SignInPage() {
setWebauthnError('');
setWebauthnLoading(true);
try {
const options = await startWebAuthnAuth();
const credential = await startAuthentication({ optionsJSON: options as any });
const verificationId = await verifyWebAuthnAuth(credential as unknown as Record<string, unknown>);
const { verificationId, authenticationOptions } = await startWebAuthnAuth();
const credential = await startAuthentication({ optionsJSON: authenticationOptions as any });
await verifyWebAuthnAuth(verificationId, credential as unknown as Record<string, unknown>);
localStorage.setItem('mfa_method_preference', 'webauthn');
const redirectTo = await submitMfa(verificationId);
window.location.replace(redirectTo);
} catch (err) {
if (err instanceof Error && err.name === 'NotAllowedError') {
setWebauthnLoading(false);
return;
}
setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed');
setWebauthnLoading(false);
}
@@ -293,6 +310,90 @@ export function SignInPage() {
}
};
// --- MFA enrollment ---
const [totpSetup, setTotpSetup] = useState<{ secret: string; secretQrCode: string; verificationId: string } | null>(null);
const [totpCode, setTotpCode] = useState('');
async function handleEnrollPasskey() {
setError(null);
setLoading(true);
try {
const { verificationId, registrationOptions } = await startWebAuthnRegistration();
const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any });
const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record<string, unknown>);
await bindMfaProfile('WebAuthn', verifiedId);
const bc = await generateBackupCodes();
await bindMfaProfile('BackupCode', bc.verificationId);
setBackupCodes(bc.codes);
setBackupCodesSaved(false);
setMode('mfaEnrollBackupCodes');
setLoading(false);
} catch (err) {
if (err instanceof Error && err.name === 'NotAllowedError') {
setLoading(false);
return;
}
setError(err instanceof Error ? err.message : 'Passkey registration failed');
setLoading(false);
}
}
async function handleStartTotpEnroll() {
setError(null);
setLoading(true);
try {
const data = await createTotpSecret();
setTotpSetup(data);
setTotpCode('');
setMode('mfaEnrollTotp');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start TOTP setup');
} finally {
setLoading(false);
}
}
async function handleVerifyTotpEnroll(e: FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const verifiedId = await verifyTotpSetup(totpCode);
await bindMfaProfile('Totp', verifiedId);
const bc = await generateBackupCodes();
await bindMfaProfile('BackupCode', bc.verificationId);
setBackupCodes(bc.codes);
setBackupCodesSaved(false);
setMode('mfaEnrollBackupCodes');
setLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verification failed');
setLoading(false);
}
}
async function handleBackupCodesDone() {
setLoading(true);
try {
const redirectTo = await submitInteraction();
window.location.replace(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to complete sign-in');
setLoading(false);
}
}
async function handleSkipEnrollment() {
setLoading(true);
try {
const redirectTo = await skipMfaEnrollment();
window.location.replace(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to continue');
setLoading(false);
}
}
const passwordToggle = (
<button
type="button"
@@ -351,16 +452,6 @@ export function SignInPage() {
</div>
</FormField>
{emailConnectorConfigured && (
<button
type="button"
className={styles.forgotLink}
onClick={() => { setError(null); setMode('forgotPassword'); }}
>
Forgot password?
</button>
)}
<Button
variant="primary"
type="submit"
@@ -371,6 +462,16 @@ export function SignInPage() {
Sign in
</Button>
{emailConnectorConfigured && (
<button
type="button"
className={styles.forgotLink}
onClick={() => { setError(null); setMode('forgotPassword'); }}
>
Forgot password?
</button>
)}
{registrationEnabled && (
<p className={styles.switchText}>
Don't have an account?{' '}
@@ -713,6 +814,116 @@ export function SignInPage() {
</div>
</div>
)}
{/* --- MFA enrollment: choose method --- */}
{mode === 'mfaEnroll' && (
<div className={styles.fields}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Secure your account</h2>
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
Add an extra layer of security to your account.
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
Use passkey
</Button>
<Button variant="secondary" onClick={handleStartTotpEnroll} disabled={loading}>
Use authenticator app
</Button>
<Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}>
Set up later
</Button>
</div>
</div>
)}
{/* --- MFA enrollment: TOTP setup --- */}
{mode === 'mfaEnrollTotp' && totpSetup && (
<div className={styles.fields}>
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Set up authenticator</h2>
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
Scan this QR code with your authenticator app, then enter the 6-digit code.
</p>
</div>
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
<img src={totpSetup.secretQrCode} alt="TOTP QR Code" width={180} height={180} />
</div>
<div style={{
textAlign: 'center', padding: '6px 10px',
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.7rem',
wordBreak: 'break-all',
}}>
{totpSetup.secret}
</div>
<form onSubmit={handleVerifyTotpEnroll} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<FormField label="Verification code" htmlFor="enroll-totp-code">
<Input
id="enroll-totp-code"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
autoFocus
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={loading} disabled={totpCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={() => { setTotpSetup(null); setMode('mfaEnroll'); }} disabled={loading}>
Back
</Button>
</div>
</form>
</div>
)}
{/* --- MFA enrollment: backup codes --- */}
{mode === 'mfaEnrollBackupCodes' && backupCodes && (
<div className={styles.fields}>
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Save your backup codes</h2>
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
Store these codes safely. Each can be used once to sign in if you lose access to your authenticator or passkey.
</p>
</div>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: '6px 20px', padding: 12,
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.85rem',
}}>
{backupCodes.map((c) => <span key={c}>{c}</span>)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(backupCodes.join('\n'))}>
Copy all
</Button>
<Button variant="secondary" onClick={() => {
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'cameleer-backup-codes.txt'; a.click();
URL.revokeObjectURL(url);
}}>
Download
</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={backupCodesSaved} onChange={(e) => setBackupCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<Button variant="primary" disabled={!backupCodesSaved} onClick={handleBackupCodesDone} loading={loading}>
Continue
</Button>
</div>
)}
</div>
</Card>
</div>

View File

@@ -71,6 +71,13 @@ export class MfaRequiredError extends Error {
}
}
export class MfaEnrollmentError extends Error {
constructor() {
super('MFA enrollment available');
this.name = 'MfaEnrollmentError';
}
}
async function trySubmit(): Promise<{ ok: true; redirectTo: string } | { ok: false; status: number; code: string; message: string }> {
const res = await request('POST', '/submit');
if (res.ok) {
@@ -92,18 +99,14 @@ export async function signIn(identifier: string, password: string): Promise<stri
const result = await trySubmit();
if (result.ok) return result.redirectTo;
// MFA already enrolled — user must verify (show TOTP input)
if (result.code === 'user.missing_mfa' || result.code === 'session.mfa.require_mfa_verification') {
// MFA already enrolled — user must verify (show TOTP/passkey input)
if (result.code === 'session.mfa.require_mfa_verification') {
throw new MfaRequiredError();
}
// MFA not enrolled, UserControlled policy — skip the binding prompt.
// Also fallback: any 422 with an MFA-related code we don't recognize — try skip before failing.
if (result.status === 422 && result.code.includes('mfa')) {
await skipMfaBinding();
const retry = await trySubmit();
if (retry.ok) return retry.redirectTo;
throw new Error(retry.message);
// MFA not enrolled — offer enrollment (passkey / TOTP)
if (result.code === 'user.missing_mfa' || (result.status === 422 && result.code.includes('mfa'))) {
throw new MfaEnrollmentError();
}
throw new Error(result.message);
@@ -184,12 +187,9 @@ export async function completeRegistration(
const result = await trySubmit();
if (result.ok) return result.redirectTo;
// MFA not enrolled, UserControlled policy — skip the binding prompt
// MFA not enrolled — offer enrollment (passkey / TOTP)
if (result.status === 422 && result.code.includes('mfa')) {
await skipMfaBinding();
const retry = await trySubmit();
if (retry.ok) return retry.redirectTo;
throw new Error(retry.message);
throw new MfaEnrollmentError();
}
throw new Error(result.message);
@@ -269,25 +269,25 @@ export async function verifyBackupCode(code: string): Promise<string> {
return data.verificationId;
}
export async function submitMfa(verificationId: string): Promise<string> {
await identifyUser(verificationId);
export async function submitMfa(_verificationId: string): Promise<string> {
// User is already identified from the initial sign-in step.
// MFA verification is stored in the experience session — just submit.
return submitInteraction();
}
// --- WebAuthn MFA Verification ---
export async function startWebAuthnAuth(): Promise<Record<string, unknown>> {
export async function startWebAuthnAuth(): Promise<{ verificationId: string; authenticationOptions: Record<string, unknown> }> {
const res = await request('POST', '/verification/web-authn/authentication');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to start passkey authentication (${res.status})`);
}
const data = await res.json();
return data;
return res.json();
}
export async function verifyWebAuthnAuth(payload: Record<string, unknown>): Promise<string> {
const res = await request('POST', '/verification/web-authn/authentication/verify', payload);
export async function verifyWebAuthnAuth(verificationId: string, payload: Record<string, unknown>): Promise<string> {
const res = await request('POST', '/verification/web-authn/authentication/verify', { verificationId, payload: { ...payload, type: 'WebAuthn' } });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (res.status === 422) {
@@ -298,3 +298,68 @@ export async function verifyWebAuthnAuth(payload: Record<string, unknown>): Prom
const data = await res.json();
return data.verificationId;
}
// --- MFA Enrollment (during sign-in) ---
export async function startWebAuthnRegistration(): Promise<{ verificationId: string; registrationOptions: Record<string, unknown> }> {
const res = await request('POST', '/verification/web-authn/registration');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to start passkey registration (${res.status})`);
}
return res.json();
}
export async function verifyWebAuthnRegistration(verificationId: string, payload: Record<string, unknown>): Promise<string> {
const body = { ...payload, type: 'WebAuthn' };
const res = await request('POST', '/verification/web-authn/registration/verify', { verificationId, payload: body });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Passkey registration verification failed (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function bindMfaProfile(type: string, verificationId: string): Promise<void> {
const res = await request('POST', '/profile/mfa', { type, verificationId });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to bind MFA (${res.status})`);
}
}
export async function generateBackupCodes(): Promise<{ verificationId: string; codes: string[] }> {
const res = await request('POST', '/verification/backup-code/generate');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to generate backup codes (${res.status})`);
}
return res.json();
}
export async function createTotpSecret(): Promise<{ secret: string; secretQrCode: string; verificationId: string }> {
const res = await request('POST', '/verification/totp/secret');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to create TOTP secret (${res.status})`);
}
return res.json();
}
export async function verifyTotpSetup(code: string): Promise<string> {
const res = await request('POST', '/verification/totp/verify', { code });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `TOTP verification failed (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function skipMfaEnrollment(): Promise<string> {
await skipMfaBinding();
const result = await trySubmit();
if (result.ok) return result.redirectTo;
throw new Error(result.message);
}

View File

@@ -44,9 +44,11 @@ export function Layout() {
refetchInterval: 30_000,
});
const isTenantAdmin = scopes.has('tenant:manage');
const onVendorRoute = location.pathname.startsWith('/vendor');
// Vendor on vendor routes: show only TENANTS. On tenant routes: show tenant portal too (for debugging).
const showTenantPortal = isTenantAdmin && (!isVendor || !onVendorRoute);
const onTenantRoute = location.pathname.startsWith('/tenant');
const onVendorRoute = location.pathname.startsWith('/vendor') || (isVendor && !onTenantRoute);
// Vendor on vendor routes (or neutral pages like account settings): show only vendor sidebar.
// On tenant routes: show tenant portal too (for debugging).
const showTenantPortal = isTenantAdmin && (!isVendor || onTenantRoute);
// Build breadcrumbs from path
const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean);

View File

@@ -8,6 +8,7 @@ import {
Card,
FormField,
Input,
Modal,
Spinner,
useToast,
} from '@cameleer/design-system';
@@ -20,7 +21,7 @@ import {
} from '../../api/account-hooks';
import styles from '../../styles/platform.module.css';
export function MfaSection() {
export function MfaSection({ bare }: { bare?: boolean }) {
const { toast } = useToast();
const { data: mfaStatus, isLoading: statusLoading } = useAccountMfaStatus();
const setup = useAccountMfaSetup();
@@ -34,6 +35,8 @@ export function MfaSection() {
const [codesSaved, setCodesSaved] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const modalOpen = !!setupData || !!codes;
async function handleStartSetup() {
try {
const data = await setup.mutateAsync();
@@ -87,6 +90,19 @@ export function MfaSection() {
}
}
function handleModalClose() {
// During backup codes step, only allow close after confirming saved
if (codes) return;
// During setup, safe to cancel — TOTP is not registered until verified
setSetupData(null);
setVerifyCode('');
}
function handleBackupCodesDone() {
setCodes(null);
setCodesSaved(false);
}
function handleCopyAll() {
if (!codes) return;
navigator.clipboard.writeText(codes.join('\n'));
@@ -105,159 +121,154 @@ export function MfaSection() {
}
if (statusLoading) {
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
</Card>
);
const spinner = <div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}><Spinner /></div>;
return bare ? spinner : <Card title="Multi-Factor Authentication">{spinner}</Card>;
}
// Backup codes display
if (codes) {
return (
<Card title="Multi-Factor Authentication">
<Alert variant="warning" title="Save your backup codes">
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
</Alert>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={() => setCodes(null)}>
Done
</Button>
</div>
</Card>
);
}
// Setup flow — QR code + verification
if (setupData) {
return (
<Card title="Multi-Factor Authentication">
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
{setupData.secretQrCode.startsWith('data:') ? (
<img src={setupData.secretQrCode} alt="TOTP QR Code" width={200} height={200} />
const content = (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? (
<Badge label="Enrolled" color="success" />
) : (
<QRCodeSVG value={setupData.secretQrCode} size={200} />
<Badge label="Not enrolled" color="auto" />
)}
</div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={() => { setSetupData(null); setVerifyCode(''); }}>
Cancel
</Button>
</div>
</form>
</Card>
);
}
// Main view — enrolled or not
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? (
<Badge label="Enrolled" color="success" />
) : (
<Badge label="Not enrolled" color="auto" />
)}
</div>
{mfaStatus?.enrolled ? (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Your account is protected with a TOTP authenticator app.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
Regenerate backup codes
</Button>
{confirmRemove ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Alert variant="error" title="This will disable MFA on your account." />
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
Confirm removal
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Your account is protected with a TOTP authenticator app.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
Regenerate backup codes
</Button>
{confirmRemove ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Alert variant="error" title="This will disable MFA on your account." />
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
Confirm removal
</Button>
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
Remove MFA
</Button>
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
)}
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</>
);
return (
<>
{bare ? content : <Card title="Multi-Factor Authentication">{content}</Card>}
<Modal
open={modalOpen}
onClose={handleModalClose}
title={codes ? 'Save your backup codes' : 'Set up authenticator'}
size="md"
>
{codes ? (
<>
<Alert variant="warning" title="Save your backup codes">
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
</Alert>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={handleBackupCodesDone}>
Done
</Button>
</div>
</>
) : setupData ? (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<QRCodeSVG value={setupData.secretQrCode} size={200} />
</div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={handleModalClose}>
Cancel
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
Remove MFA
</Button>
)}
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</Card>
</form>
</>
) : null}
</Modal>
</>
);
}

View File

@@ -39,7 +39,7 @@ export function PasskeyNudgeBanner() {
);
}
export function PasskeySection() {
export function PasskeySection({ bare }: { bare?: boolean }) {
const { toast } = useToast();
const { data: passkeys, isLoading } = useAccountPasskeyList();
const renamePasskey = useAccountRenamePasskey();
@@ -85,17 +85,17 @@ export function PasskeySection() {
if (isLoading) return null;
const credentials = passkeys ?? [];
return (
<Card title="Passkeys">
const content = (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Use your fingerprint, face, or security key to sign in faster.
</p>
{credentials.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
No passkeys registered. Passkeys can be registered during sign-in when prompted.
No passkeys registered. You can register a passkey during sign-in.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ maxHeight: 240, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 12 }}>
{credentials.map((pk) => (
<div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}>
@@ -107,9 +107,9 @@ export function PasskeySection() {
</div>
) : (
<>
<div style={{ fontWeight: 500 }}>{pk.name || 'Unnamed passkey'}</div>
<div style={{ fontWeight: 500 }}>{pk.name || parseAgent(pk.agent)}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{parseAgent(pk.agent)} &middot; Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
</div>
</>
)}
@@ -131,6 +131,8 @@ export function PasskeySection() {
))}
</div>
)}
</Card>
</>
);
return bare ? content : <Card title="Passkeys">{content}</Card>;
}

View File

@@ -20,7 +20,6 @@ export function OnboardingPage() {
const [error, setError] = useState<string | null>(null);
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
const [checkingSlug, setCheckingSlug] = useState(false);
const [showPasskeyOffer, setShowPasskeyOffer] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const slug = toSlug(name);
@@ -51,17 +50,6 @@ export function OnboardingPage() {
setLoading(true);
try {
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
// Check if passkeys are enabled in vendor policy
try {
const config = await fetch('/platform/api/config').then(r => r.json());
if (config.vendorAuthPolicy?.passkeyEnabled) {
setShowPasskeyOffer(true);
setLoading(false);
return; // Don't redirect yet
}
} catch {
// Ignore — proceed without passkey offer
}
// Tenant created — force a fresh OIDC sign-in so the Logto SDK gets
// new tokens that include the org membership just created. The existing
// Logto session cookie means the user won't see a login form — Logto
@@ -78,34 +66,6 @@ export function OnboardingPage() {
}
}
async function handleSkipPasskey() {
await signIn(`${window.location.origin}/platform/callback`);
}
if (showPasskeyOffer) {
return (
<div className={styles.page}>
<div className={styles.wrapper}>
<Card className={styles.card}>
<div className={styles.inner}>
<div style={{ textAlign: 'center' }}>
<h2 style={{ margin: '16px 0 8px' }}>Secure your account</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: 24 }}>
Add a passkey to sign in faster with your fingerprint, face, or security key.
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Button variant="secondary" onClick={handleSkipPasskey}>
Set up later
</Button>
</div>
</div>
</Card>
</div>
</div>
);
}
return (
<div className={styles.page}>
<div className={styles.wrapper}>

View File

@@ -13,14 +13,10 @@ import {
import {
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 { PasskeySection } from '../../components/account/PasskeySection';
import { useScopes } from '../../auth/useScopes';
import { tierColor } from '../../utils/tier';
import styles from '../../styles/platform.module.css';
function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
@@ -33,78 +29,6 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
}
}
function MfaEnforcementToggle() {
const scopes = useScopes();
const { toast } = useToast();
const { data: settings } = useTenantSettings();
const updateSettings = useUpdateTenantSettings();
const [confirmEnable, setConfirmEnable] = useState(false);
if (!scopes.has('tenant:manage')) return null;
const mfaRequired = settings?.mfaRequired ?? false;
async function handleToggle() {
if (!mfaRequired) {
setConfirmEnable(true);
return;
}
try {
await updateSettings.mutateAsync({ mfaRequired: false });
toast({ title: 'MFA requirement disabled for all members', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' });
}
}
async function handleConfirmEnable() {
try {
await updateSettings.mutateAsync({ mfaRequired: true });
setConfirmEnable(false);
toast({ title: 'MFA is now required for all members', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' });
}
}
return (
<Card title="MFA Enforcement">
<p className={styles.description} style={{ marginTop: 0 }}>
When enabled, all team members will be required to set up multi-factor authentication before accessing this tenant.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: '0.875rem' }}>Require MFA for all members</span>
<Badge label={mfaRequired ? 'Required' : 'Optional'} color={mfaRequired ? 'success' : 'auto'} />
</div>
{confirmEnable ? (
<div style={{ marginTop: 12 }}>
<Alert variant="warning" title="Confirm MFA requirement">
All team members who have not enrolled in MFA will need to set it up on their next login. Are you sure?
</Alert>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="primary" onClick={handleConfirmEnable} loading={updateSettings.isPending}>
Yes, require MFA
</Button>
<Button variant="secondary" onClick={() => setConfirmEnable(false)}>
Cancel
</Button>
</div>
</div>
) : (
<div style={{ marginTop: 12 }}>
<Button
variant={mfaRequired ? 'danger' : 'primary'}
onClick={handleToggle}
loading={updateSettings.isPending}
>
{mfaRequired ? 'Disable MFA requirement' : 'Enable MFA requirement'}
</Button>
</div>
)}
</Card>
);
}
function AuthPolicySection() {
const scopes = useScopes();
const { toast } = useToast();
@@ -212,89 +136,86 @@ export function SettingsPage() {
}
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflowY: 'auto', flex: 1 }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Settings</h1>
<PasskeyNudgeBanner />
<Card title="Tenant Details">
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Name</span>
<span className={styles.kvValue}>{data.name}</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20 }}>
{/* Card 1: Tenant Details */}
<div style={{ maxWidth: 520, flex: '1 1 400px' }}>
<Card title="Tenant Details">
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Name</span>
<span className={styles.kvValue}>{data.name}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Slug</span>
<span className={styles.kvValueMono}>{data.slug}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Status</span>
<Badge label={data.status} color={statusColor(data.status)} />
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Server Endpoint</span>
<span className={styles.kvValueMono}>{data.serverEndpoint ?? ''}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Created</span>
<span className={styles.kvValue}>{new Date(data.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Slug</span>
<span className={styles.kvValueMono}>{data.slug}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Tier</span>
<Badge label={data.tier} color={tierColor(data.tier)} />
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Status</span>
<Badge label={data.status} color={statusColor(data.status)} />
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Server Endpoint</span>
<span className={styles.kvValueMono}>{data.serverEndpoint ?? ''}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Created</span>
<span className={styles.kvValue}>{new Date(data.createdAt).toLocaleDateString()}</span>
<div style={{ borderTop: '1px solid var(--border)', marginTop: 16, paddingTop: 16 }}>
<p className={styles.description} style={{ margin: '0 0 12px' }}>
Reset the built-in admin password for your server dashboard (local login at <code>/login?local</code>).
</p>
<form
onSubmit={async (e) => {
e.preventDefault();
if (serverAdminPw.length < 8) {
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
return;
}
try {
await resetServerAdmin.mutateAsync(serverAdminPw);
toast({ title: 'Server admin password reset successfully', variant: 'success' });
setServerAdminPw('');
} catch (err) {
toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' });
}
}}
style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}
>
<div style={{ flex: 1 }}>
<FormField label="Server admin password" htmlFor="server-admin-pw">
<Input
id="server-admin-pw"
type="password"
value={serverAdminPw}
onChange={(e) => setServerAdminPw(e.target.value)}
placeholder="Enter new admin password"
required
minLength={8}
/>
</FormField>
</div>
<Button type="submit" variant="primary" loading={resetServerAdmin.isPending}>
Reset
</Button>
</form>
</div>
</Card>
</div>
<p className={styles.description} style={{ marginTop: 16 }}>
To change your tier or other billing-related settings, please contact support.
</p>
</Card>
{/* Card 2: Authentication Policy (org-wide settings) */}
<div style={{ maxWidth: 520, flex: '1 1 400px' }}>
<AuthPolicySection />
</div>
</div>
<PasswordChangeSection />
<Card title="Server Admin Password">
<p className={styles.description} style={{ marginTop: 0 }}>
Reset the built-in admin password for your server dashboard (local login at <code>/login?local</code>).
</p>
<form
onSubmit={async (e) => {
e.preventDefault();
if (serverAdminPw.length < 8) {
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
return;
}
try {
await resetServerAdmin.mutateAsync(serverAdminPw);
toast({ title: 'Server admin password reset successfully', variant: 'success' });
setServerAdminPw('');
} catch (err) {
toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' });
}
}}
style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}
>
<FormField label="New admin password" htmlFor="server-admin-pw">
<Input
id="server-admin-pw"
type="password"
value={serverAdminPw}
onChange={(e) => setServerAdminPw(e.target.value)}
placeholder="Enter new admin password"
required
minLength={8}
/>
</FormField>
<div>
<Button type="submit" variant="primary" loading={resetServerAdmin.isPending}>
Reset Admin Password
</Button>
</div>
</form>
</Card>
<MfaSection />
<MfaEnforcementToggle />
{/* Card 3: Passkeys (full width) */}
<PasskeySection />
<AuthPolicySection />
</div>
);
}