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
+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>