diff --git a/backend/src/auth/passkeys.rs b/backend/src/auth/passkeys.rs index e2050d2..907b924 100644 --- a/backend/src/auth/passkeys.rs +++ b/backend/src/auth/passkeys.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; -use diesel::prelude::*; +use diesel::{dsl::count_star, prelude::*}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; @@ -325,6 +325,19 @@ impl PasskeyService { Ok(passkeys.into_iter().map(PasskeySummary::from).collect()) } + pub fn active_passkey_count( + &self, + conn: &mut PgConnection, + user_id: Uuid, + ) -> AppResult { + let count: i64 = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user_id)) + .filter(passkey_dsl::revoked_at.is_null()) + .select(count_star()) + .first(conn)?; + Ok(count) + } + pub fn revoke_passkey( &self, conn: &mut PgConnection, diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs index 9d7ff16..990ceff 100644 --- a/backend/src/routes/profile.rs +++ b/backend/src/routes/profile.rs @@ -130,6 +130,13 @@ pub async fn delete_passkey( .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + let active_count = service.active_passkey_count(&mut conn, user_id)?; + if active_count <= 1 { + return Err(AppError::bad_request( + "cannot revoke the last remaining passkey", + )); + } + service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?; no_content() } diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 84afce2..539727c 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -36,6 +36,12 @@ import { createPreviewSurface } from './preview/PreviewWorkspace'; import { createDesktopSurface } from './DesktopWorkspace'; import { AppShellContext, useAppShell } from './appShellContext'; import LoginView from './login/LoginView'; +import usePasskeys from './settings/usePasskeys'; +import { + isWebAuthnAvailable, + preparePublicKeyRequestOptions, + serializeAuthenticationCredential, +} from './utils/webauthn'; const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; @@ -1733,12 +1739,29 @@ const AppLayout = () => { setWebdavTokenSecret(null); }, []); + const { + passkeys, + passkeysSupported, + passkeysLoading, + registeringPasskey, + revokingPasskeyId, + refreshPasskeys, + registerPasskey, + revokePasskey, + } = usePasskeys({ + api, + notifyApiError, + setStatusMessage, + token, + }); + useEffect(() => { if (!isSettingsModalOpen) { return; } refreshWebdavTokens(); - }, [isSettingsModalOpen, refreshWebdavTokens]); + refreshPasskeys(); + }, [isSettingsModalOpen, refreshPasskeys, refreshWebdavTokens]); useEffect(() => { if (!isSettingsModalOpen) { @@ -5463,6 +5486,14 @@ const AppLayout = () => { onDelete={deleteWebdavToken} createdToken={webdavTokenSecret} onDismissCreatedToken={dismissCreatedWebdavToken} + passkeys={passkeys} + passkeysSupported={passkeysSupported} + passkeysLoading={passkeysLoading} + registeringPasskey={registeringPasskey} + revokingPasskeyId={revokingPasskeyId} + onRefreshPasskeys={refreshPasskeys} + onRegisterPasskey={registerPasskey} + onRevokePasskey={revokePasskey} /> @@ -5666,6 +5697,8 @@ const LoginRoute = () => { const location = useLocation(); const [status, setStatus] = useState(null); const [selectingTenantId, setSelectingTenantId] = useState(null); + const passkeySupported = isWebAuthnAvailable(); + const [passkeyLoading, setPasskeyLoading] = useState(false); const setStatusMessage = useCallback((message, variant = 'info') => { setStatus(message ? { message, variant } : null); @@ -5683,52 +5716,6 @@ const LoginRoute = () => { [reportLoginError], ); - const handleLogin = useCallback( - async (event) => { - event.preventDefault(); - const form = new FormData(event.currentTarget); - const payload = { - username: form.get('username')?.toString().trim(), - password: form.get('password')?.toString() || '', - }; - - if (!payload.username || !payload.password) { - setStatusMessage('Username and password are required.', 'error'); - return; - } - - try { - appDispatch({ type: 'LOGIN_REQUEST' }); - const { data } = await api.post('/auth/login', payload); - if (data?.access_token && Array.isArray(data?.tenants)) { - appDispatch({ - type: 'TENANT_SELECTION_REQUIRED', - selectionToken: data.access_token, - tenants: data.tenants, - }); - setStatusMessage('Select a tenant to continue.', 'info'); - return; - } - - if (!data?.access_token) { - throw new Error('Invalid login response.'); - } - - appDispatch({ - type: 'LOGIN_SUCCESS', - token: data.access_token, - tenant: data.tenant || null, - }); - setStatusMessage('Login successful.', 'success'); - } catch (error) { - const message = error?.response?.data?.error || 'Login failed. Check credentials.'; - appDispatch({ type: 'LOGIN_FAILURE', error: message }); - notifyLoginError(error, message); - } - }, - [appDispatch, notifyLoginError, setStatusMessage], - ); - const handleTenantSelect = useCallback( async (tenant) => { if (!tenantSelection?.selectionToken || !tenant?.id) { @@ -5772,6 +5759,92 @@ const LoginRoute = () => { setStatusMessage(null); }, [appDispatch, setStatusMessage]); + const handlePasskeyLogin = useCallback( + async (rawUsername) => { + const username = (rawUsername || '').trim(); + if (!username) { + setStatusMessage('Enter your username before using a passkey.', 'error'); + return; + } + if (!passkeySupported) { + setStatusMessage('Passkeys are not supported in this browser.', 'error'); + return; + } + + setPasskeyLoading(true); + appDispatch({ type: 'LOGIN_REQUEST' }); + try { + const { data: startData } = await api.post('/auth/passkeys/login/start', { username }); + const challengeId = startData?.challengeId || startData?.challenge_id; + const publicKeyOptions = + startData?.publicKey + || startData?.public_key + || startData?.publicKeyCredentialRequestOptions + || startData?.challenge?.publicKey + || startData?.challenge?.public_key; + + if (!challengeId || !publicKeyOptions) { + throw new Error('Invalid passkey challenge response.'); + } + + const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions }); + setStatusMessage('Confirm the passkey prompt to continue.', 'info'); + const assertion = await navigator.credentials.get({ publicKey }); + + if (!assertion) { + setStatusMessage('Passkey login cancelled.', 'info'); + return; + } + + const serialized = serializeAuthenticationCredential(assertion); + const finishPayload = { + challengeId, + credential: serialized, + }; + + const { data: finishData } = await api.post('/auth/passkeys/login/finish', finishPayload); + + if (finishData?.access_token && Array.isArray(finishData?.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: finishData.access_token, + tenants: finishData.tenants, + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!finishData?.access_token) { + throw new Error('Invalid login response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: finishData.access_token, + tenant: finishData.tenant || null, + }); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + if (error?.name === 'NotAllowedError') { + setStatusMessage('Passkey login cancelled.', 'info'); + } else if (error?.response?.status === 404) { + setStatusMessage('No passkeys found for this user. Sign in with your password to add one.', 'error'); + appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' }); + } else if (error?.response?.status === 400 && error?.response?.data?.error) { + setStatusMessage(error.response.data.error, 'error'); + appDispatch({ type: 'LOGIN_FAILURE', error: error.response.data.error }); + } else { + const message = error?.response?.data?.error || error.message || 'Passkey login failed.'; + notifyLoginError(error, message); + appDispatch({ type: 'LOGIN_FAILURE', error: message }); + } + } finally { + setPasskeyLoading(false); + } + }, + [appDispatch, notifyLoginError, passkeySupported, setStatusMessage], + ); + const redirectTarget = useMemo(() => { const target = location.state?.from; if (typeof target === 'string' && target.startsWith('/')) { @@ -5787,12 +5860,14 @@ const LoginRoute = () => { return (
); diff --git a/frontend/src/login/LoginView.jsx b/frontend/src/login/LoginView.jsx index 346716e..c769a6a 100644 --- a/frontend/src/login/LoginView.jsx +++ b/frontend/src/login/LoginView.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; const StatusBanner = ({ status }) => { if (!status) return null; @@ -6,14 +6,24 @@ const StatusBanner = ({ status }) => { }; const LoginView = ({ - onSubmit, status, tenantSelection, onSelectTenant, onCancelSelection, selectingTenantId, + onPasskeyLogin, + passkeySupported = false, + passkeyLoading = false, }) => { const hasTenantSelection = Boolean(tenantSelection?.tenants?.length); + const [username, setUsername] = useState(''); + + const handlePasskeyClick = () => { + if (!onPasskeyLogin) { + return; + } + onPasskeyLogin(username); + }; return (
@@ -50,27 +60,29 @@ const LoginView = ({
) : ( <> -

Authenticate to manage your documents.

-
- - - - - -
+

Use your registered passkey to sign in.

+ + setUsername(event.target.value)} + placeholder="Username" + autoComplete="username" + required + /> + {passkeySupported ? ( + + ) : ( +

Passkeys are not supported in this browser.

+ )} )} @@ -80,4 +92,3 @@ const LoginView = ({ }; export default LoginView; - diff --git a/frontend/src/settings/SettingsModal.jsx b/frontend/src/settings/SettingsModal.jsx index 72fb77b..3dde965 100644 --- a/frontend/src/settings/SettingsModal.jsx +++ b/frontend/src/settings/SettingsModal.jsx @@ -1,6 +1,10 @@ import React, { useMemo, useState, useCallback, useEffect } from 'react'; const SECTIONS = [ + { + id: 'passkeys', + label: 'Passkeys', + }, { id: 'webdav', label: 'WebDAV', @@ -19,12 +23,21 @@ const SettingsModal = ({ onDelete, createdToken = null, onDismissCreatedToken, + passkeys = [], + passkeysSupported = null, + passkeysLoading = false, + registeringPasskey = false, + revokingPasskeyId = null, + onRefreshPasskeys, + onRegisterPasskey, + onRevokePasskey, }) => { - const defaultSection = SECTIONS[0]?.id || 'webdav'; + const defaultSection = SECTIONS[0]?.id || 'passkeys'; const [activeSection, setActiveSection] = useState(defaultSection); const [newTokenLabel, setNewTokenLabel] = useState(''); const [newTokenExpires, setNewTokenExpires] = useState(''); const [formError, setFormError] = useState(null); + const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); const handleBackdropClick = useCallback(() => { onClose?.(); @@ -40,6 +53,7 @@ const SettingsModal = ({ setNewTokenLabel(''); setNewTokenExpires(''); setFormError(null); + setNewPasskeyNickname(''); } }, [open, defaultSection]); @@ -71,6 +85,34 @@ const SettingsModal = ({ onDismissCreatedToken?.(); }, [onDismissCreatedToken]); + const handlePasskeyRefresh = useCallback(() => { + onRefreshPasskeys?.(); + }, [onRefreshPasskeys]); + + const handlePasskeyRegister = useCallback( + async (event) => { + event.preventDefault(); + const nickname = newPasskeyNickname.trim(); + const result = await onRegisterPasskey?.({ nickname }); + if (result?.ok) { + setNewPasskeyNickname(''); + } + }, + [newPasskeyNickname, onRegisterPasskey], + ); + + const handlePasskeyRevoke = useCallback( + async (passkey) => { + if (!passkey?.id) { + return; + } + const reasonInput = window.prompt('Optional reason for revoking this passkey:', ''); + const reason = reasonInput ? reasonInput.trim() : undefined; + await onRevokePasskey?.(passkey.id, reason); + }, + [onRevokePasskey], + ); + const handleCreateToken = useCallback( async (event) => { event.preventDefault(); @@ -232,6 +274,126 @@ const SettingsModal = ({ handleDismissSecret, ]); + const renderPasskeysSection = useMemo(() => { + const hasPasskeys = Array.isArray(passkeys) && passkeys.length > 0; + + return ( +
+
+ +
+ + {passkeysSupported === false ? ( +

Passkeys are not enabled for this account.

+ ) : ( + <> +
+
+ + setNewPasskeyNickname(event.target.value)} + disabled={registeringPasskey} + /> +
+
+ +
+
+ + {passkeysLoading && !hasPasskeys ? ( +

Loading passkeys…

+ ) : null} + + {!passkeysLoading && !hasPasskeys ? ( +

No passkeys registered yet.

+ ) : null} + + {hasPasskeys ? ( + + + + + + + + + + + + + {passkeys.map((passkey) => { + const createdAt = passkey.created_at || passkey.createdAt; + const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt; + const revokedAt = passkey.revoked_at || passkey.revokedAt; + const revokedReason = passkey.revoked_reason || passkey.revokedReason; + const revoked = Boolean(revokedAt); + const transports = Array.isArray(passkey.transports) + ? passkey.transports.filter(Boolean) + : []; + + return ( + + + + + + + + + ); + })} + +
NicknameCreatedLast usedTransportsStatusActions
{passkey.nickname || '—'}{formatDateTime(createdAt)}{formatDateTime(lastUsedAt)}{transports.length ? transports.join(', ') : '—'} + {revoked + ? revokedReason + ? `Revoked (${revokedReason})` + : 'Revoked' + : 'Active'} + + {revoked ? ( + Revoked + ) : ( + + )} +
+ ) : null} + + )} +
+ ); + }, [ + passkeys, + passkeysLoading, + passkeysSupported, + registeringPasskey, + revokingPasskeyId, + newPasskeyNickname, + formatDateTime, + handlePasskeyRefresh, + handlePasskeyRegister, + handlePasskeyRevoke, + ]); + if (!open) { return null; } @@ -268,7 +430,11 @@ const SettingsModal = ({
- {activeSection === 'webdav' ? renderWebdavSection :

Select a settings section.

} + {activeSection === 'passkeys' ? renderPasskeysSection : null} + {activeSection === 'webdav' ? renderWebdavSection : null} + {activeSection !== 'passkeys' && activeSection !== 'webdav' ? ( +

Select a settings section.

+ ) : null}