Compare commits
17 Commits
feature/ve
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,22 @@ 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 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) {
|
||||
|
||||
@@ -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;
|
||||
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';
|
||||
|
||||
const SIGN_IN_SUBTITLES = [
|
||||
"Prove you're not a mirage",
|
||||
@@ -137,6 +140,11 @@ export function SignInPage() {
|
||||
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);
|
||||
}
|
||||
@@ -293,6 +306,75 @@ 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);
|
||||
const result = await submitInteraction();
|
||||
window.location.replace(result);
|
||||
} 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);
|
||||
const result = await submitInteraction();
|
||||
window.location.replace(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verification failed');
|
||||
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 +433,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 +443,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 +795,77 @@ 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>
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
<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>
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
<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>
|
||||
)}
|
||||
</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,8 +269,9 @@ 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();
|
||||
}
|
||||
|
||||
@@ -298,3 +299,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';
|
||||
@@ -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'));
|
||||
@@ -114,150 +130,146 @@ export function MfaSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// Backup codes display
|
||||
if (codes) {
|
||||
return (
|
||||
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} />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function PasskeySection() {
|
||||
</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 }}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -212,7 +212,7 @@ 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 />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user