passkeys
This commit is contained in:
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
|
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
|
||||||
use diesel::prelude::*;
|
use diesel::{dsl::count_star, prelude::*};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -325,6 +325,19 @@ impl PasskeyService {
|
|||||||
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
|
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(
|
pub fn revoke_passkey(
|
||||||
&self,
|
&self,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ pub async fn delete_passkey(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
.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)?;
|
service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?;
|
||||||
no_content()
|
no_content()
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-48
@@ -36,6 +36,12 @@ import { createPreviewSurface } from './preview/PreviewWorkspace';
|
|||||||
import { createDesktopSurface } from './DesktopWorkspace';
|
import { createDesktopSurface } from './DesktopWorkspace';
|
||||||
import { AppShellContext, useAppShell } from './appShellContext';
|
import { AppShellContext, useAppShell } from './appShellContext';
|
||||||
import LoginView from './login/LoginView';
|
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 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'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
@@ -1733,12 +1739,29 @@ const AppLayout = () => {
|
|||||||
setWebdavTokenSecret(null);
|
setWebdavTokenSecret(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
passkeys,
|
||||||
|
passkeysSupported,
|
||||||
|
passkeysLoading,
|
||||||
|
registeringPasskey,
|
||||||
|
revokingPasskeyId,
|
||||||
|
refreshPasskeys,
|
||||||
|
registerPasskey,
|
||||||
|
revokePasskey,
|
||||||
|
} = usePasskeys({
|
||||||
|
api,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSettingsModalOpen) {
|
if (!isSettingsModalOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refreshWebdavTokens();
|
refreshWebdavTokens();
|
||||||
}, [isSettingsModalOpen, refreshWebdavTokens]);
|
refreshPasskeys();
|
||||||
|
}, [isSettingsModalOpen, refreshPasskeys, refreshWebdavTokens]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSettingsModalOpen) {
|
if (!isSettingsModalOpen) {
|
||||||
@@ -5463,6 +5486,14 @@ const AppLayout = () => {
|
|||||||
onDelete={deleteWebdavToken}
|
onDelete={deleteWebdavToken}
|
||||||
createdToken={webdavTokenSecret}
|
createdToken={webdavTokenSecret}
|
||||||
onDismissCreatedToken={dismissCreatedWebdavToken}
|
onDismissCreatedToken={dismissCreatedWebdavToken}
|
||||||
|
passkeys={passkeys}
|
||||||
|
passkeysSupported={passkeysSupported}
|
||||||
|
passkeysLoading={passkeysLoading}
|
||||||
|
registeringPasskey={registeringPasskey}
|
||||||
|
revokingPasskeyId={revokingPasskeyId}
|
||||||
|
onRefreshPasskeys={refreshPasskeys}
|
||||||
|
onRegisterPasskey={registerPasskey}
|
||||||
|
onRevokePasskey={revokePasskey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppShellContext.Provider>
|
</AppShellContext.Provider>
|
||||||
@@ -5666,6 +5697,8 @@ const LoginRoute = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
||||||
|
const passkeySupported = isWebAuthnAvailable();
|
||||||
|
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||||
setStatus(message ? { message, variant } : null);
|
setStatus(message ? { message, variant } : null);
|
||||||
@@ -5683,52 +5716,6 @@ const LoginRoute = () => {
|
|||||||
[reportLoginError],
|
[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(
|
const handleTenantSelect = useCallback(
|
||||||
async (tenant) => {
|
async (tenant) => {
|
||||||
if (!tenantSelection?.selectionToken || !tenant?.id) {
|
if (!tenantSelection?.selectionToken || !tenant?.id) {
|
||||||
@@ -5772,6 +5759,92 @@ const LoginRoute = () => {
|
|||||||
setStatusMessage(null);
|
setStatusMessage(null);
|
||||||
}, [appDispatch, setStatusMessage]);
|
}, [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 redirectTarget = useMemo(() => {
|
||||||
const target = location.state?.from;
|
const target = location.state?.from;
|
||||||
if (typeof target === 'string' && target.startsWith('/')) {
|
if (typeof target === 'string' && target.startsWith('/')) {
|
||||||
@@ -5787,12 +5860,14 @@ const LoginRoute = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<LoginView
|
<LoginView
|
||||||
onSubmit={handleLogin}
|
|
||||||
status={status}
|
status={status}
|
||||||
tenantSelection={tenantSelection}
|
tenantSelection={tenantSelection}
|
||||||
onSelectTenant={handleTenantSelect}
|
onSelectTenant={handleTenantSelect}
|
||||||
onCancelSelection={handleCancelSelection}
|
onCancelSelection={handleCancelSelection}
|
||||||
selectingTenantId={selectingTenantId}
|
selectingTenantId={selectingTenantId}
|
||||||
|
onPasskeyLogin={handlePasskeyLogin}
|
||||||
|
passkeySupported={passkeySupported}
|
||||||
|
passkeyLoading={passkeyLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
const StatusBanner = ({ status }) => {
|
const StatusBanner = ({ status }) => {
|
||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
@@ -6,14 +6,24 @@ const StatusBanner = ({ status }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LoginView = ({
|
const LoginView = ({
|
||||||
onSubmit,
|
|
||||||
status,
|
status,
|
||||||
tenantSelection,
|
tenantSelection,
|
||||||
onSelectTenant,
|
onSelectTenant,
|
||||||
onCancelSelection,
|
onCancelSelection,
|
||||||
selectingTenantId,
|
selectingTenantId,
|
||||||
|
onPasskeyLogin,
|
||||||
|
passkeySupported = false,
|
||||||
|
passkeyLoading = false,
|
||||||
}) => {
|
}) => {
|
||||||
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
|
||||||
|
const handlePasskeyClick = () => {
|
||||||
|
if (!onPasskeyLogin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPasskeyLogin(username);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-screen">
|
<div className="login-screen">
|
||||||
@@ -50,27 +60,29 @@ const LoginView = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>Authenticate to manage your documents.</p>
|
<p>Use your registered passkey to sign in.</p>
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
<label htmlFor="username">Username</label>
|
<label htmlFor="username">Username</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
placeholder="admin"
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Username"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<label htmlFor="password">Password</label>
|
{passkeySupported ? (
|
||||||
<input
|
<button
|
||||||
id="password"
|
type="button"
|
||||||
name="password"
|
className="secondary login-card__passkey-button"
|
||||||
type="password"
|
onClick={handlePasskeyClick}
|
||||||
placeholder="••••••"
|
disabled={passkeyLoading || !username.trim()}
|
||||||
autoComplete="current-password"
|
>
|
||||||
required
|
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
|
||||||
/>
|
</button>
|
||||||
<button type="submit">Sign in</button>
|
) : (
|
||||||
</form>
|
<p className="settings-empty">Passkeys are not supported in this browser.</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<StatusBanner status={status} />
|
<StatusBanner status={status} />
|
||||||
@@ -80,4 +92,3 @@ const LoginView = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default LoginView;
|
export default LoginView;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: 'passkeys',
|
||||||
|
label: 'Passkeys',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'webdav',
|
id: 'webdav',
|
||||||
label: 'WebDAV',
|
label: 'WebDAV',
|
||||||
@@ -19,12 +23,21 @@ const SettingsModal = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
createdToken = null,
|
createdToken = null,
|
||||||
onDismissCreatedToken,
|
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 [activeSection, setActiveSection] = useState(defaultSection);
|
||||||
const [newTokenLabel, setNewTokenLabel] = useState('');
|
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||||
const [newTokenExpires, setNewTokenExpires] = useState('');
|
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||||
const [formError, setFormError] = useState(null);
|
const [formError, setFormError] = useState(null);
|
||||||
|
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
||||||
|
|
||||||
const handleBackdropClick = useCallback(() => {
|
const handleBackdropClick = useCallback(() => {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
@@ -40,6 +53,7 @@ const SettingsModal = ({
|
|||||||
setNewTokenLabel('');
|
setNewTokenLabel('');
|
||||||
setNewTokenExpires('');
|
setNewTokenExpires('');
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
setNewPasskeyNickname('');
|
||||||
}
|
}
|
||||||
}, [open, defaultSection]);
|
}, [open, defaultSection]);
|
||||||
|
|
||||||
@@ -71,6 +85,34 @@ const SettingsModal = ({
|
|||||||
onDismissCreatedToken?.();
|
onDismissCreatedToken?.();
|
||||||
}, [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(
|
const handleCreateToken = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -232,6 +274,126 @@ const SettingsModal = ({
|
|||||||
handleDismissSecret,
|
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) {
|
if (!open) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -268,7 +430,11 @@ const SettingsModal = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="settings-modal__content">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user