refactor: move passkey enrollment to sign-in UI via Experience API
Remove the SaaS backend proxy approach for passkey registration (Account API binding, Management API proxy, password modal in PasskeySection). Instead, offer passkey enrollment natively during sign-in via Logto's Experience API — the correct architectural layer. Sign-in flow: when Logto returns MFA enrollment available (422), show a "Secure your account" screen with Register passkey / Set up later. Uses Experience API WebAuthn registration endpoints. Works for all users (SaaS and future server users) since the sign-in UI is shared. PasskeySection in account settings now only manages existing passkeys (list/rename/delete) and directs users to register during sign-in. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -103,17 +103,6 @@ public class AccountController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WebAuthn bind (proxied via Management API since Account API /my-account/ returns 401) ---
|
|
||||||
|
|
||||||
record WebAuthnBindRequest(String verificationRecordId, String identityVerificationId) {}
|
|
||||||
|
|
||||||
@PostMapping("/mfa/webauthn/bind")
|
|
||||||
public ResponseEntity<Void> bindWebAuthn(@AuthenticationPrincipal Jwt jwt,
|
|
||||||
@RequestBody WebAuthnBindRequest request) {
|
|
||||||
accountService.bindWebAuthnPasskey(jwt.getSubject(), request.verificationRecordId(), request.identityVerificationId());
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- MFA Preference ---
|
// --- MFA Preference ---
|
||||||
|
|
||||||
@PostMapping("/mfa/method-preference")
|
@PostMapping("/mfa/method-preference")
|
||||||
|
|||||||
@@ -150,10 +150,6 @@ public class AccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void bindWebAuthnPasskey(String userId, String verificationRecordId, String identityVerificationId) {
|
|
||||||
logtoClient.bindWebAuthnMfa(userId, verificationRecordId, identityVerificationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Passkeys ---
|
// --- Passkeys ---
|
||||||
|
|
||||||
public List<PasskeyCredential> listPasskeys(String userId) {
|
public List<PasskeyCredential> listPasskeys(String userId) {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs Logto configuration on startup: enables the Account Center
|
* Syncs Logto configuration on startup: ensures MFA factors
|
||||||
* and ensures MFA factors (including WebAuthn) match the vendor policy.
|
* (including WebAuthn) match the vendor policy.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class LogtoStartupConfig {
|
public class LogtoStartupConfig {
|
||||||
@@ -32,7 +32,6 @@ public class LogtoStartupConfig {
|
|||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void onStartup() {
|
public void onStartup() {
|
||||||
logtoClient.enableAccountCenter();
|
|
||||||
syncMfaFactors();
|
syncMfaFactors();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -653,40 +653,6 @@ public class LogtoManagementClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bind a WebAuthn passkey to a user via the Management API. */
|
|
||||||
public void bindWebAuthnMfa(String userId, String verificationRecordId, String identityVerificationId) {
|
|
||||||
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
|
||||||
restClient.post()
|
|
||||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
|
||||||
.header("Authorization", "Bearer " + getAccessToken())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(Map.of("type", "WebAuthn", "verificationRecordId", verificationRecordId))
|
|
||||||
.retrieve()
|
|
||||||
.toBodilessEntity();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enable the Account Center API for MFA management (WebAuthn passkey registration). */
|
|
||||||
public void enableAccountCenter() {
|
|
||||||
if (!isAvailable()) return;
|
|
||||||
try {
|
|
||||||
restClient.patch()
|
|
||||||
.uri(config.getLogtoEndpoint() + "/api/account-center")
|
|
||||||
.header("Authorization", "Bearer " + getAccessToken())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(Map.of(
|
|
||||||
"enabled", true,
|
|
||||||
"fields", Map.of(
|
|
||||||
"mfa", "Edit"
|
|
||||||
)
|
|
||||||
))
|
|
||||||
.retrieve()
|
|
||||||
.toBodilessEntity();
|
|
||||||
log.info("Account Center enabled for MFA management");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to enable Account Center: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update user custom data (partial merge). Used for mfa_method_preference. */
|
/** Update user custom data (partial merge). Used for mfa_method_preference. */
|
||||||
public void updateUserCustomData(String userId, Map<String, Object> customData) {
|
public void updateUserCustomData(String userId, Map<String, Object> customData) {
|
||||||
if (!isAvailable()) return;
|
if (!isAvailable()) return;
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ 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,
|
||||||
|
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';
|
||||||
|
|
||||||
const SIGN_IN_SUBTITLES = [
|
const SIGN_IN_SUBTITLES = [
|
||||||
"Prove you're not a mirage",
|
"Prove you're not a mirage",
|
||||||
@@ -137,6 +139,11 @@ export function SignInPage() {
|
|||||||
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 +186,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);
|
||||||
}
|
}
|
||||||
@@ -293,6 +305,38 @@ export function SignInPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- MFA enrollment: passkey registration ---
|
||||||
|
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 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 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"
|
||||||
@@ -713,6 +757,27 @@ export function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* --- MFA enrollment: offer passkey registration --- */}
|
||||||
|
{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 a passkey to sign in faster with your fingerprint, face, or security key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{error && <Alert variant="error">{error}</Alert>}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
|
||||||
|
Register passkey
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}>
|
||||||
|
Set up later
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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) {
|
||||||
@@ -97,13 +104,9 @@ export async function signIn(identifier: string, password: string): Promise<stri
|
|||||||
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.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);
|
||||||
@@ -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);
|
||||||
@@ -299,3 +299,40 @@ 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 skipMfaEnrollment(): Promise<string> {
|
||||||
|
await skipMfaBinding();
|
||||||
|
const result = await trySubmit();
|
||||||
|
if (result.ok) return result.redirectTo;
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { startRegistration } from '@simplewebauthn/browser';
|
|
||||||
import { api } from './client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logto Account API client for WebAuthn passkey registration.
|
|
||||||
* Calls Logto's Account API endpoints directly (same domain).
|
|
||||||
* The bind step goes through the SaaS backend (Management API).
|
|
||||||
*/
|
|
||||||
|
|
||||||
async function accountApi(
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
token: string,
|
|
||||||
body?: unknown,
|
|
||||||
extraHeaders?: Record<string, string>,
|
|
||||||
): Promise<Response> {
|
|
||||||
return fetch(`/api${path}`, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
||||||
...extraHeaders,
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verify user's password via Account API. Returns a verification record ID. */
|
|
||||||
export async function verifyPassword(
|
|
||||||
token: string,
|
|
||||||
password: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const res = await accountApi('POST', '/verifications/password', token, { password });
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(err.message || 'Password verification failed');
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
return data.verificationRecordId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full passkey registration flow: verify password → WebAuthn ceremony → bind. */
|
|
||||||
export async function registerPasskey(
|
|
||||||
getAccountToken: () => Promise<string>,
|
|
||||||
password: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const token = await getAccountToken();
|
|
||||||
|
|
||||||
// Step 1: Verify password for sensitive operation
|
|
||||||
const identityVerificationId = await verifyPassword(token, password);
|
|
||||||
|
|
||||||
// Step 2: Get registration options from Logto Account API
|
|
||||||
const optionsRes = await accountApi('POST', '/verifications/web-authn/registration', token);
|
|
||||||
if (!optionsRes.ok) {
|
|
||||||
const err = await optionsRes.json().catch(() => ({}));
|
|
||||||
throw new Error(err.message || `Failed to start passkey registration (${optionsRes.status})`);
|
|
||||||
}
|
|
||||||
const { registrationOptions, verificationRecordId } = await optionsRes.json();
|
|
||||||
|
|
||||||
// Step 3: Browser WebAuthn ceremony
|
|
||||||
const credential = await startRegistration({ optionsJSON: registrationOptions });
|
|
||||||
|
|
||||||
// Step 4: Verify the registration with Logto
|
|
||||||
const verifyRes = await accountApi(
|
|
||||||
'POST',
|
|
||||||
'/verifications/web-authn/registration/verify',
|
|
||||||
token,
|
|
||||||
{ verificationRecordId, payload: { ...credential, type: 'WebAuthn' } },
|
|
||||||
);
|
|
||||||
if (!verifyRes.ok) {
|
|
||||||
const err = await verifyRes.json().catch(() => ({}));
|
|
||||||
throw new Error(err.message || `Passkey verification failed (${verifyRes.status})`);
|
|
||||||
}
|
|
||||||
const verifyData = await verifyRes.json();
|
|
||||||
const verifiedRecordId = verifyData.verificationRecordId;
|
|
||||||
|
|
||||||
// Step 5: Bind via SaaS backend (Management API) — Logto's /api/my-account/
|
|
||||||
// rejects the opaque token, so we proxy through our backend.
|
|
||||||
await api.post('/account/mfa/webauthn/bind', {
|
|
||||||
verificationRecordId: verifiedRecordId,
|
|
||||||
identityVerificationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLogto } from '@logto/react';
|
|
||||||
import { errorMessage } from '../../api/client';
|
import { errorMessage } from '../../api/client';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FormField,
|
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +13,6 @@ import {
|
|||||||
useAccountRenamePasskey,
|
useAccountRenamePasskey,
|
||||||
useAccountDeletePasskey,
|
useAccountDeletePasskey,
|
||||||
} from '../../api/account-hooks';
|
} from '../../api/account-hooks';
|
||||||
import { registerPasskey } from '../../api/logto-account-api';
|
|
||||||
import styles from '../../styles/platform.module.css';
|
import styles from '../../styles/platform.module.css';
|
||||||
|
|
||||||
export function PasskeyNudgeBanner() {
|
export function PasskeyNudgeBanner() {
|
||||||
@@ -45,17 +41,12 @@ export function PasskeyNudgeBanner() {
|
|||||||
|
|
||||||
export function PasskeySection() {
|
export function PasskeySection() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { getAccessToken } = useLogto();
|
const { data: passkeys, isLoading } = useAccountPasskeyList();
|
||||||
const { data: passkeys, isLoading, refetch } = useAccountPasskeyList();
|
|
||||||
const renamePasskey = useAccountRenamePasskey();
|
const renamePasskey = useAccountRenamePasskey();
|
||||||
const deletePasskey = useAccountDeletePasskey();
|
const deletePasskey = useAccountDeletePasskey();
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const [registering, setRegistering] = useState(false);
|
|
||||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
|
||||||
const [regPassword, setRegPassword] = useState('');
|
|
||||||
const [regError, setRegError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function parseAgent(agent: string | null): string {
|
function parseAgent(agent: string | null): string {
|
||||||
if (!agent) return 'Unknown device';
|
if (!agent) return 'Unknown device';
|
||||||
@@ -81,37 +72,6 @@ export function PasskeySection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRegister() {
|
|
||||||
setRegPassword('');
|
|
||||||
setRegError(null);
|
|
||||||
setShowPasswordModal(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRegister(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setRegError(null);
|
|
||||||
setRegistering(true);
|
|
||||||
try {
|
|
||||||
await registerPasskey(async () => {
|
|
||||||
const token = await getAccessToken();
|
|
||||||
if (!token) throw new Error('Not authenticated');
|
|
||||||
return token;
|
|
||||||
}, regPassword);
|
|
||||||
setShowPasswordModal(false);
|
|
||||||
await refetch();
|
|
||||||
toast({ title: 'Passkey registered', variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
// User cancelled the WebAuthn prompt — not an error
|
|
||||||
if (err instanceof Error && err.name === 'NotAllowedError') {
|
|
||||||
setRegistering(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRegError(errorMessage(err));
|
|
||||||
} finally {
|
|
||||||
setRegistering(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
try {
|
try {
|
||||||
await deletePasskey.mutateAsync(id);
|
await deletePasskey.mutateAsync(id);
|
||||||
@@ -126,19 +86,13 @@ export function PasskeySection() {
|
|||||||
const credentials = passkeys ?? [];
|
const credentials = passkeys ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Card title="Passkeys">
|
<Card title="Passkeys">
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
Use your fingerprint, face, or security key to sign in faster.
|
Use your fingerprint, face, or security key to sign in faster.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<Button variant="primary" onClick={openRegister}>
|
|
||||||
Register passkey
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{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 yet.
|
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 }}>
|
||||||
@@ -178,39 +132,5 @@ export function PasskeySection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={showPasswordModal}
|
|
||||||
onClose={() => { if (!registering) setShowPasswordModal(false); }}
|
|
||||||
title="Confirm identity"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
Enter your password to register a new passkey.
|
|
||||||
</p>
|
|
||||||
{regError && <Alert variant="error">{regError}</Alert>}
|
|
||||||
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<FormField label="Password" htmlFor="passkey-reg-password">
|
|
||||||
<Input
|
|
||||||
id="passkey-reg-password"
|
|
||||||
type="password"
|
|
||||||
value={regPassword}
|
|
||||||
onChange={(e) => setRegPassword(e.target.value)}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
autoFocus
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button type="submit" variant="primary" loading={registering} disabled={!regPassword}>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setShowPasswordModal(false)} disabled={registering}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user