This commit is contained in:
2025-10-30 00:22:01 +01:00
parent d72792fe0a
commit 4d14d5a3d4
5 changed files with 347 additions and 75 deletions
+14 -1
View File
@@ -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<i64> {
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,
+7
View File
@@ -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()
}
+123 -48
View File
@@ -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}
/>
</div>
</AppShellContext.Provider>
@@ -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 (
<div className="app-shell">
<LoginView
onSubmit={handleLogin}
status={status}
tenantSelection={tenantSelection}
onSelectTenant={handleTenantSelect}
onCancelSelection={handleCancelSelection}
selectingTenantId={selectingTenantId}
onPasskeyLogin={handlePasskeyLogin}
passkeySupported={passkeySupported}
passkeyLoading={passkeyLoading}
/>
</div>
);
+35 -24
View File
@@ -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 (
<div className="login-screen">
@@ -50,27 +60,29 @@ const LoginView = ({
</div>
) : (
<>
<p>Authenticate to manage your documents.</p>
<form onSubmit={onSubmit}>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
placeholder="admin"
autoComplete="username"
required
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
placeholder="••••••"
autoComplete="current-password"
required
/>
<button type="submit">Sign in</button>
</form>
<p>Use your registered passkey to sign in.</p>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Username"
autoComplete="username"
required
/>
{passkeySupported ? (
<button
type="button"
className="secondary login-card__passkey-button"
onClick={handlePasskeyClick}
disabled={passkeyLoading || !username.trim()}
>
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
</button>
) : (
<p className="settings-empty">Passkeys are not supported in this browser.</p>
)}
</>
)}
<StatusBanner status={status} />
@@ -80,4 +92,3 @@ const LoginView = ({
};
export default LoginView;
+168 -2
View File
@@ -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 (
<div className="settings-section">
<div className="settings-actions">
<button
type="button"
className="secondary"
onClick={handlePasskeyRefresh}
disabled={passkeysLoading}
>
{passkeysLoading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{passkeysSupported === false ? (
<p className="settings-empty">Passkeys are not enabled for this account.</p>
) : (
<>
<form className="settings-form" onSubmit={handlePasskeyRegister}>
<div className="settings-form__field">
<label htmlFor="passkey-nickname">Nickname (optional)</label>
<input
id="passkey-nickname"
type="text"
placeholder="e.g. MacBook"
value={newPasskeyNickname}
onChange={(event) => setNewPasskeyNickname(event.target.value)}
disabled={registeringPasskey}
/>
</div>
<div className="settings-form__actions">
<button type="submit" disabled={registeringPasskey}>
{registeringPasskey ? 'Registering…' : 'Register passkey'}
</button>
</div>
</form>
{passkeysLoading && !hasPasskeys ? (
<p className="settings-empty">Loading passkeys</p>
) : null}
{!passkeysLoading && !hasPasskeys ? (
<p className="settings-empty">No passkeys registered yet.</p>
) : null}
{hasPasskeys ? (
<table className="settings-table">
<thead>
<tr>
<th scope="col">Nickname</th>
<th scope="col">Created</th>
<th scope="col">Last used</th>
<th scope="col">Transports</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={passkey.id} className={revoked ? 'is-revoked' : undefined}>
<td>{passkey.nickname || '—'}</td>
<td>{formatDateTime(createdAt)}</td>
<td>{formatDateTime(lastUsedAt)}</td>
<td>{transports.length ? transports.join(', ') : '—'}</td>
<td>
{revoked
? revokedReason
? `Revoked (${revokedReason})`
: 'Revoked'
: 'Active'}
</td>
<td className="settings-table__actions">
{revoked ? (
<span className="settings-status">Revoked</span>
) : (
<button
type="button"
className="danger"
onClick={() => handlePasskeyRevoke(passkey)}
disabled={revokingPasskeyId === passkey.id}
>
{revokingPasskeyId === passkey.id ? 'Revoking…' : 'Revoke'}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</>
)}
</div>
);
}, [
passkeys,
passkeysLoading,
passkeysSupported,
registeringPasskey,
revokingPasskeyId,
newPasskeyNickname,
formatDateTime,
handlePasskeyRefresh,
handlePasskeyRegister,
handlePasskeyRevoke,
]);
if (!open) {
return null;
}
@@ -268,7 +430,11 @@ const SettingsModal = ({
</ul>
</nav>
<div className="settings-modal__content">
{activeSection === 'webdav' ? renderWebdavSection : <p>Select a settings section.</p>}
{activeSection === 'passkeys' ? renderPasskeysSection : null}
{activeSection === 'webdav' ? renderWebdavSection : null}
{activeSection !== 'passkeys' && activeSection !== 'webdav' ? (
<p>Select a settings section.</p>
) : null}
</div>
</div>
</div>