Compare commits
22 Commits
feature/ve
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06134d6e67 | ||
|
|
7fe9c581b0 | ||
|
|
7fc8a4d407 | ||
|
|
e21a9d6046 | ||
|
|
0481cefaf4 | ||
|
|
040ae60be5 | ||
|
|
d8f7452ab7 | ||
|
|
c4fe16048c | ||
|
|
cba420fbeb | ||
|
|
67ec409383 | ||
|
|
3384510f3c | ||
|
|
18e6f32f90 | ||
|
|
4df6fc9e03 | ||
|
|
2aa5100530 | ||
|
|
c360d9ad5f | ||
|
|
e7952dd9de | ||
|
|
687598952f | ||
|
|
c22580e124 | ||
|
|
a5c20830a7 | ||
|
|
9231a1fc60 | ||
| f325416833 | |||
| 0b4d0e3b2f |
@@ -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.
|
||||
|
||||
|
||||
@@ -616,7 +616,7 @@ api_patch "/api/sign-in-exp" '{
|
||||
]
|
||||
},
|
||||
"mfa": {
|
||||
"factors": ["Totp", "BackupCode"],
|
||||
"factors": ["Totp", "WebAuthn", "BackupCode"],
|
||||
"policy": "UserControlled"
|
||||
}
|
||||
}' >/dev/null 2>&1
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)} · 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>;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user