Compare commits
19 Commits
feature/ve
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> 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": {
|
"mfa": {
|
||||||
"factors": ["Totp", "BackupCode"],
|
"factors": ["Totp", "WebAuthn", "BackupCode"],
|
||||||
"policy": "UserControlled"
|
"policy": "UserControlled"
|
||||||
}
|
}
|
||||||
}' >/dev/null 2>&1
|
}' >/dev/null 2>&1
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class AccountController {
|
|||||||
@PostMapping("/mfa/totp/verify")
|
@PostMapping("/mfa/totp/verify")
|
||||||
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
|
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
|
||||||
@RequestBody TotpVerifyRequest request) {
|
@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);
|
return Map.of("verified", ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ public class AccountService {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||||
}
|
}
|
||||||
|
Object nameVal = user.get("name");
|
||||||
|
Object emailVal = user.get("primaryEmail");
|
||||||
return new ProfileData(
|
return new ProfileData(
|
||||||
userId,
|
userId,
|
||||||
String.valueOf(user.getOrDefault("name", "")),
|
nameVal != null ? String.valueOf(nameVal) : "",
|
||||||
String.valueOf(user.getOrDefault("primaryEmail", ""))
|
emailVal != null ? String.valueOf(emailVal) : ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +110,22 @@ public class AccountService {
|
|||||||
new SecureRandom().nextBytes(secretBytes);
|
new SecureRandom().nextBytes(secretBytes);
|
||||||
String secret = base32Encode(secretBytes);
|
String secret = base32Encode(secretBytes);
|
||||||
|
|
||||||
var result = logtoClient.createTotpVerification(userId, secret);
|
// Build otpauth URI locally — do NOT register with Logto yet.
|
||||||
String qrCode = result.containsKey("secretQrCode")
|
// The secret is only registered after the user verifies the 6-digit code.
|
||||||
? String.valueOf(result.get("secretQrCode"))
|
var user = logtoClient.getUser(userId);
|
||||||
: String.valueOf(result.getOrDefault("qrCode", ""));
|
String email = user != null ? String.valueOf(user.getOrDefault("primaryEmail", "")) : "";
|
||||||
return new MfaSetupData(secret, qrCode);
|
String label = email.isBlank() ? userId : email;
|
||||||
|
String otpauthUri = String.format(
|
||||||
|
"otpauth://totp/Cameleer:%s?secret=%s&issuer=Cameleer&algorithm=SHA1&digits=6&period=30",
|
||||||
|
label, secret);
|
||||||
|
|
||||||
|
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) {
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,13 +100,12 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-muted);
|
color: var(--text-link, #C6820E);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-decoration: underline;
|
text-decoration: none;
|
||||||
text-align: right;
|
text-align: center;
|
||||||
align-self: flex-end;
|
align-self: center;
|
||||||
margin-top: -8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.forgotLink:hover {
|
.forgotLink:hover {
|
||||||
|
|||||||
@@ -2,17 +2,20 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
|
||||||
import {
|
import {
|
||||||
signIn, startRegistration, completeRegistration,
|
signIn, startRegistration, completeRegistration,
|
||||||
startForgotPassword, forgotPasswordVerifyAndReset,
|
startForgotPassword, forgotPasswordVerifyAndReset,
|
||||||
verifyTotp, verifyBackupCode, submitMfa,
|
verifyTotp, verifyBackupCode, submitMfa,
|
||||||
startWebAuthnAuth, verifyWebAuthnAuth,
|
startWebAuthnAuth, verifyWebAuthnAuth,
|
||||||
MfaRequiredError,
|
startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile,
|
||||||
|
generateBackupCodes, createTotpSecret, verifyTotpSetup,
|
||||||
|
skipMfaEnrollment, submitInteraction,
|
||||||
|
MfaRequiredError, MfaEnrollmentError,
|
||||||
} from './experience-api';
|
} from './experience-api';
|
||||||
import styles from './SignInPage.module.css';
|
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 = [
|
const SIGN_IN_SUBTITLES = [
|
||||||
"Prove you're not a mirage",
|
"Prove you're not a mirage",
|
||||||
@@ -87,6 +90,8 @@ export function SignInPage() {
|
|||||||
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||||
const [webauthnError, setWebauthnError] = useState('');
|
const [webauthnError, setWebauthnError] = useState('');
|
||||||
const [webauthnLoading, setWebauthnLoading] = useState(false);
|
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
|
// Fetch sign-in experience to check if registration is enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,16 +132,19 @@ export function SignInPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof MfaRequiredError) {
|
if (err instanceof MfaRequiredError) {
|
||||||
const pref = localStorage.getItem('mfa_method_preference');
|
const pref = localStorage.getItem('mfa_method_preference');
|
||||||
if (pref === 'webauthn') {
|
if (pref === 'totp') {
|
||||||
setMode('mfaWebauthn');
|
|
||||||
} else if (pref === 'totp') {
|
|
||||||
setMode('mfaVerify');
|
setMode('mfaVerify');
|
||||||
} else {
|
} else {
|
||||||
setMode('mfaMethodPicker');
|
setMode('mfaWebauthn');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (err instanceof MfaEnrollmentError) {
|
||||||
|
setMode('mfaEnroll');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -179,6 +187,11 @@ export function SignInPage() {
|
|||||||
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
|
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
|
||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof MfaEnrollmentError) {
|
||||||
|
setMode('mfaEnroll');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : 'Verification failed');
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -258,13 +271,17 @@ export function SignInPage() {
|
|||||||
setWebauthnError('');
|
setWebauthnError('');
|
||||||
setWebauthnLoading(true);
|
setWebauthnLoading(true);
|
||||||
try {
|
try {
|
||||||
const options = await startWebAuthnAuth();
|
const { verificationId, authenticationOptions } = await startWebAuthnAuth();
|
||||||
const credential = await startAuthentication({ optionsJSON: options as any });
|
const credential = await startAuthentication({ optionsJSON: authenticationOptions as any });
|
||||||
const verificationId = await verifyWebAuthnAuth(credential as unknown as Record<string, unknown>);
|
await verifyWebAuthnAuth(verificationId, credential as unknown as Record<string, unknown>);
|
||||||
localStorage.setItem('mfa_method_preference', 'webauthn');
|
localStorage.setItem('mfa_method_preference', 'webauthn');
|
||||||
const redirectTo = await submitMfa(verificationId);
|
const redirectTo = await submitMfa(verificationId);
|
||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'NotAllowedError') {
|
||||||
|
setWebauthnLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed');
|
setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed');
|
||||||
setWebauthnLoading(false);
|
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 = (
|
const passwordToggle = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -351,16 +452,6 @@ export function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{emailConnectorConfigured && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.forgotLink}
|
|
||||||
onClick={() => { setError(null); setMode('forgotPassword'); }}
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -371,6 +462,16 @@ export function SignInPage() {
|
|||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{emailConnectorConfigured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.forgotLink}
|
||||||
|
onClick={() => { setError(null); setMode('forgotPassword'); }}
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{registrationEnabled && (
|
{registrationEnabled && (
|
||||||
<p className={styles.switchText}>
|
<p className={styles.switchText}>
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
@@ -713,6 +814,116 @@ export function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 }> {
|
async function trySubmit(): Promise<{ ok: true; redirectTo: string } | { ok: false; status: number; code: string; message: string }> {
|
||||||
const res = await request('POST', '/submit');
|
const res = await request('POST', '/submit');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -92,18 +99,14 @@ export async function signIn(identifier: string, password: string): Promise<stri
|
|||||||
const result = await trySubmit();
|
const result = await trySubmit();
|
||||||
if (result.ok) return result.redirectTo;
|
if (result.ok) return result.redirectTo;
|
||||||
|
|
||||||
// MFA already enrolled — user must verify (show TOTP input)
|
// MFA already enrolled — user must verify (show TOTP/passkey input)
|
||||||
if (result.code === 'user.missing_mfa' || result.code === 'session.mfa.require_mfa_verification') {
|
if (result.code === 'session.mfa.require_mfa_verification') {
|
||||||
throw new MfaRequiredError();
|
throw new MfaRequiredError();
|
||||||
}
|
}
|
||||||
|
|
||||||
// MFA not enrolled, UserControlled policy — skip the binding prompt.
|
// MFA not enrolled — offer enrollment (passkey / TOTP)
|
||||||
// Also fallback: any 422 with an MFA-related code we don't recognize — try skip before failing.
|
if (result.code === 'user.missing_mfa' || (result.status === 422 && result.code.includes('mfa'))) {
|
||||||
if (result.status === 422 && result.code.includes('mfa')) {
|
throw new MfaEnrollmentError();
|
||||||
await skipMfaBinding();
|
|
||||||
const retry = await trySubmit();
|
|
||||||
if (retry.ok) return retry.redirectTo;
|
|
||||||
throw new Error(retry.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(result.message);
|
throw new Error(result.message);
|
||||||
@@ -184,12 +187,9 @@ export async function completeRegistration(
|
|||||||
const result = await trySubmit();
|
const result = await trySubmit();
|
||||||
if (result.ok) return result.redirectTo;
|
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')) {
|
if (result.status === 422 && result.code.includes('mfa')) {
|
||||||
await skipMfaBinding();
|
throw new MfaEnrollmentError();
|
||||||
const retry = await trySubmit();
|
|
||||||
if (retry.ok) return retry.redirectTo;
|
|
||||||
throw new Error(retry.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(result.message);
|
throw new Error(result.message);
|
||||||
@@ -269,25 +269,25 @@ export async function verifyBackupCode(code: string): Promise<string> {
|
|||||||
return data.verificationId;
|
return data.verificationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitMfa(verificationId: string): Promise<string> {
|
export async function submitMfa(_verificationId: string): Promise<string> {
|
||||||
await identifyUser(verificationId);
|
// User is already identified from the initial sign-in step.
|
||||||
|
// MFA verification is stored in the experience session — just submit.
|
||||||
return submitInteraction();
|
return submitInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WebAuthn MFA Verification ---
|
// --- 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');
|
const res = await request('POST', '/verification/web-authn/authentication');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.message || `Failed to start passkey authentication (${res.status})`);
|
throw new Error(err.message || `Failed to start passkey authentication (${res.status})`);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
return res.json();
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyWebAuthnAuth(payload: Record<string, unknown>): Promise<string> {
|
export async function verifyWebAuthnAuth(verificationId: string, payload: Record<string, unknown>): Promise<string> {
|
||||||
const res = await request('POST', '/verification/web-authn/authentication/verify', payload);
|
const res = await request('POST', '/verification/web-authn/authentication/verify', { verificationId, payload: { ...payload, type: 'WebAuthn' } });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
if (res.status === 422) {
|
if (res.status === 422) {
|
||||||
@@ -298,3 +298,68 @@ export async function verifyWebAuthnAuth(payload: Record<string, unknown>): Prom
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.verificationId;
|
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,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
const isTenantAdmin = scopes.has('tenant:manage');
|
const isTenantAdmin = scopes.has('tenant:manage');
|
||||||
const onVendorRoute = location.pathname.startsWith('/vendor');
|
const onTenantRoute = location.pathname.startsWith('/tenant');
|
||||||
// Vendor on vendor routes: show only TENANTS. On tenant routes: show tenant portal too (for debugging).
|
const onVendorRoute = location.pathname.startsWith('/vendor') || (isVendor && !onTenantRoute);
|
||||||
const showTenantPortal = isTenantAdmin && (!isVendor || !onVendorRoute);
|
// 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
|
// Build breadcrumbs from path
|
||||||
const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean);
|
const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
FormField,
|
FormField,
|
||||||
Input,
|
Input,
|
||||||
|
Modal,
|
||||||
Spinner,
|
Spinner,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
@@ -34,6 +35,8 @@ export function MfaSection() {
|
|||||||
const [codesSaved, setCodesSaved] = useState(false);
|
const [codesSaved, setCodesSaved] = useState(false);
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
|
||||||
|
const modalOpen = !!setupData || !!codes;
|
||||||
|
|
||||||
async function handleStartSetup() {
|
async function handleStartSetup() {
|
||||||
try {
|
try {
|
||||||
const data = await setup.mutateAsync();
|
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() {
|
function handleCopyAll() {
|
||||||
if (!codes) return;
|
if (!codes) return;
|
||||||
navigator.clipboard.writeText(codes.join('\n'));
|
navigator.clipboard.writeText(codes.join('\n'));
|
||||||
@@ -114,150 +130,146 @@ export function MfaSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup codes display
|
return (
|
||||||
if (codes) {
|
<>
|
||||||
return (
|
|
||||||
<Card title="Multi-Factor Authentication">
|
<Card title="Multi-Factor Authentication">
|
||||||
<Alert variant="warning" title="Save your backup codes">
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
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.
|
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
|
||||||
</Alert>
|
{mfaStatus?.enrolled ? (
|
||||||
<div style={{
|
<Badge label="Enrolled" color="success" />
|
||||||
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} />
|
|
||||||
) : (
|
) : (
|
||||||
<QRCodeSVG value={setupData.secretQrCode} size={200} />
|
<Badge label="Not enrolled" color="auto" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{mfaStatus?.enrolled ? (
|
||||||
<Badge label="Enrolled" color="success" />
|
<>
|
||||||
) : (
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
<Badge label="Not enrolled" color="auto" />
|
Your account is protected with a TOTP authenticator app.
|
||||||
)}
|
</p>
|
||||||
</div>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
{mfaStatus?.enrolled ? (
|
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
|
||||||
<>
|
Regenerate backup codes
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
</Button>
|
||||||
Your account is protected with a TOTP authenticator app.
|
{confirmRemove ? (
|
||||||
</p>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<Alert variant="error" title="This will disable MFA on your account." />
|
||||||
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
|
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
|
||||||
Regenerate backup codes
|
Confirm removal
|
||||||
</Button>
|
</Button>
|
||||||
{confirmRemove ? (
|
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
Cancel
|
||||||
<Alert variant="error" title="This will disable MFA on your account." />
|
</Button>
|
||||||
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
|
</div>
|
||||||
Confirm removal
|
) : (
|
||||||
|
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
|
||||||
|
Remove MFA
|
||||||
</Button>
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</form>
|
||||||
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
|
</>
|
||||||
Remove MFA
|
) : null}
|
||||||
</Button>
|
</Modal>
|
||||||
)}
|
</>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function PasskeySection() {
|
|||||||
</p>
|
</p>
|
||||||
{credentials.length === 0 ? (
|
{credentials.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export function OnboardingPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
|
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
|
||||||
const [checkingSlug, setCheckingSlug] = useState(false);
|
const [checkingSlug, setCheckingSlug] = useState(false);
|
||||||
const [showPasskeyOffer, setShowPasskeyOffer] = useState(false);
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
const slug = toSlug(name);
|
const slug = toSlug(name);
|
||||||
@@ -51,17 +50,6 @@ export function OnboardingPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
|
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
|
// Tenant created — force a fresh OIDC sign-in so the Logto SDK gets
|
||||||
// new tokens that include the org membership just created. The existing
|
// new tokens that include the org membership just created. The existing
|
||||||
// Logto session cookie means the user won't see a login form — Logto
|
// 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 (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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>
|
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Settings</h1>
|
||||||
<PasskeyNudgeBanner />
|
<PasskeyNudgeBanner />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user