Files
papercrate/frontend/src/settings/SettingsModal.jsx
T
2025-11-05 15:21:02 +01:00

609 lines
20 KiB
React

import React, { useMemo, useState, useCallback, useEffect } from 'react';
import PanelHeader from '../ui/PanelHeader';
const SECTIONS = [
{
id: 'passkeys',
label: 'Passkeys',
},
{
id: 'apiTokens',
label: 'API tokens',
},
];
const SettingsModal = ({
open,
onClose,
tokens = [],
loading = false,
creating = false,
deletingId = null,
regeneratingId = null,
capabilitySets = [],
capabilitySetsLoading = false,
onRefresh,
onCreate,
onDelete,
onRegenerate,
createdToken = null,
onDismissCreatedToken,
passkeys = [],
passkeysSupported = null,
passkeysLoading = false,
registeringPasskey = false,
revokingPasskeyId = null,
onRefreshPasskeys,
onRegisterPasskey,
onRevokePasskey,
}) => {
const defaultSection = SECTIONS[0]?.id || 'passkeys';
const [activeSection, setActiveSection] = useState(defaultSection);
const [newTokenLabel, setNewTokenLabel] = useState('');
const [newTokenExpires, setNewTokenExpires] = useState('');
const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState('');
const [formError, setFormError] = useState(null);
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
const handleBackdropClick = useCallback(() => {
onClose?.();
}, [onClose]);
const handleInnerClick = useCallback((event) => {
event.stopPropagation();
}, []);
useEffect(() => {
if (!open) {
setActiveSection(defaultSection);
setNewTokenLabel('');
setNewTokenExpires('');
setNewTokenCapabilitySetId('');
setFormError(null);
setNewPasskeyNickname('');
}
}, [open, defaultSection]);
const formatDateTime = useCallback((value) => {
if (!value) {
return '—';
}
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) {
return value;
}
return timestamp.toLocaleString();
}, []);
const handleRefresh = useCallback(() => {
onRefresh?.();
}, [onRefresh]);
const handleCopyToken = useCallback(() => {
if (!createdToken) {
return;
}
if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(createdToken).catch(() => {});
}
}, [createdToken]);
const handleDismissSecret = useCallback(() => {
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 capabilitySetOptions = useMemo(
() => capabilitySets.map((set) => ({
value: set.id,
label: set.label || set.slug || set.id,
capabilities: Array.isArray(set.capabilities) ? set.capabilities : [],
})),
[capabilitySets],
);
const capabilitySetMap = useMemo(
() => Object.fromEntries(capabilitySetOptions.map((option) => [option.value, option])),
[capabilitySetOptions],
);
useEffect(() => {
if (!capabilitySetOptions.length) {
setNewTokenCapabilitySetId('');
return;
}
if (!newTokenCapabilitySetId
|| !capabilitySetOptions.some((option) => option.value === newTokenCapabilitySetId)) {
setNewTokenCapabilitySetId(capabilitySetOptions[0].value);
}
}, [capabilitySetOptions, newTokenCapabilitySetId]);
const handleNewCapabilitySetChange = useCallback((event) => {
setFormError(null);
setNewTokenCapabilitySetId(event.target.value);
}, []);
const handleCreateToken = useCallback(
async (event) => {
event.preventDefault();
setFormError(null);
let normalizedLabel = newTokenLabel.trim();
if (normalizedLabel.length === 0) {
normalizedLabel = undefined;
}
let normalizedExpires;
if (newTokenExpires) {
const parsed = new Date(newTokenExpires);
if (Number.isNaN(parsed.getTime())) {
setFormError('Enter a valid expiration date.');
return;
}
normalizedExpires = parsed.toISOString();
}
if (!capabilitySetOptions.length) {
setFormError('Capability sets are still loading.');
return;
}
const selectedCapabilitySetId = newTokenCapabilitySetId
|| capabilitySetOptions[0]?.value;
if (!selectedCapabilitySetId) {
setFormError('Select a capability set.');
return;
}
const result = await onCreate?.({
label: normalizedLabel,
expires_at: normalizedExpires,
capability_set_id: selectedCapabilitySetId,
});
if (result !== false) {
setNewTokenLabel('');
setNewTokenExpires('');
setNewTokenCapabilitySetId(capabilitySetOptions[0]?.value || '');
setFormError(null);
}
},
[
capabilitySetOptions,
newTokenCapabilitySetId,
newTokenExpires,
newTokenLabel,
onCreate,
],
);
const handleRegenerateToken = useCallback(
async (token) => {
if (!token?.id) {
return;
}
await onRegenerate?.(token.id);
},
[onRegenerate],
);
const renderApiTokensSection = useMemo(() => {
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
return (
<div className="settings-section">
<div className="settings-actions">
<button
type="button"
className="secondary"
onClick={handleRefresh}
disabled={loading || capabilitySetsLoading}
>
{loading || capabilitySetsLoading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
<p>
API tokens use predefined capability sets. Choose the set that matches the access you
need when creating or updating a token.
</p>
{createdToken ? (
<div className="settings-notice">
<p>
Copy this token now; you will not be able to view it again after closing this window.
</p>
<pre className="token-display">{createdToken}</pre>
<div className="settings-notice__actions">
<button type="button" className="secondary" onClick={handleCopyToken}>
Copy token
</button>
<button type="button" onClick={handleDismissSecret}>
Dismiss
</button>
</div>
</div>
) : null}
<form className="settings-form" onSubmit={handleCreateToken}>
<div className="settings-form__field">
<label htmlFor="api-token-label">Label</label>
<input
id="api-token-label"
type="text"
value={newTokenLabel}
onChange={(event) => setNewTokenLabel(event.target.value)}
placeholder="Personal API token"
/>
</div>
<div className="settings-form__field">
<label htmlFor="api-token-expires">Expires at</label>
<input
id="api-token-expires"
type="datetime-local"
value={newTokenExpires}
onChange={(event) => setNewTokenExpires(event.target.value)}
/>
</div>
<div className="settings-form__field">
<label htmlFor="api-token-capability-set">Capability set</label>
<select
id="api-token-capability-set"
value={newTokenCapabilitySetId}
onChange={handleNewCapabilitySetChange}
disabled={
creating
|| capabilitySetsLoading
|| !capabilitySetOptions.length
}
>
{!capabilitySetOptions.length ? (
<option value="">No capability sets available</option>
) : null}
{capabilitySetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{capabilitySetsLoading ? (
<small>Loading capability sets</small>
) : null}
{!capabilitySetsLoading && !capabilitySetOptions.length ? (
<small>No capability sets available.</small>
) : null}
{!capabilitySetsLoading
&& capabilitySetMap[newTokenCapabilitySetId]?.capabilities?.length ? (
<small>
Includes: {capabilitySetMap[newTokenCapabilitySetId].capabilities.join(', ')}
</small>
) : null}
</div>
<div className="settings-form__actions">
<button
type="submit"
disabled={
creating
|| capabilitySetsLoading
|| !capabilitySetOptions.length
}
>
{creating ? 'Creating…' : 'Create token'}
</button>
</div>
</form>
{formError ? <p className="settings-form__error">{formError}</p> : null}
{loading && !hasTokens ? (
<p className="settings-empty">Loading tokens</p>
) : null}
{!loading && !hasTokens ? (
<p className="settings-empty">No API tokens yet.</p>
) : null}
{hasTokens ? (
<table className="settings-table">
<thead>
<tr>
<th scope="col">Label</th>
<th scope="col">Created</th>
<th scope="col">Last used</th>
<th scope="col">Expires</th>
<th scope="col">Capability set</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{tokens.map((token) => {
const isRevoked = Boolean(token?.revoked_at);
const selectedSet = token?.capability_set_id
? capabilitySetMap[token.capability_set_id]
: null;
const capabilityList = Array.isArray(token?.capabilities) && token.capabilities.length
? token.capabilities
: selectedSet?.capabilities || [];
const capabilitySetLabel = selectedSet?.label
|| selectedSet?.slug
|| token.capability_set_id
|| '—';
return (
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
<td>{token.label || '—'}</td>
<td>{formatDateTime(token.created_at)}</td>
<td>{formatDateTime(token.last_used_at)}</td>
<td>{formatDateTime(token.expires_at)}</td>
<td>
<div className="settings-capability-set">
<span className="settings-capability-set__label">{capabilitySetLabel}</span>
</div>
{capabilitySetsLoading ? (
<small>Loading capability sets</small>
) : null}
{!capabilitySetsLoading && capabilityList.length ? (
<div className="settings-capabilities">
<small>Includes: {capabilityList.join(', ')}</small>
</div>
) : null}
</td>
<td className="settings-table__actions">
{isRevoked ? (
<span className="settings-status">Revoked</span>
) : (
<>
<button
type="button"
className="secondary"
onClick={() => handleRegenerateToken(token)}
disabled={
regeneratingId === token.id || deletingId === token.id
}
>
{regeneratingId === token.id ? 'Regenerating…' : 'Regenerate'}
</button>
<button
type="button"
className="danger"
onClick={() => onDelete?.(token.id)}
disabled={
deletingId === token.id || regeneratingId === token.id
}
>
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
</button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</div>
);
}, [
tokens,
loading,
createdToken,
creating,
deletingId,
regeneratingId,
newTokenLabel,
newTokenExpires,
newTokenCapabilitySetId,
formError,
capabilitySetOptions,
capabilitySetMap,
capabilitySetsLoading,
formatDateTime,
handleCopyToken,
handleCreateToken,
handleRegenerateToken,
handleRefresh,
onDelete,
handleDismissSecret,
handleNewCapabilitySetChange,
]);
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;
}
return (
<div className="modal-backdrop" role="presentation" onClick={handleBackdropClick}>
<div
className="modal modal--panel settings-modal"
role="dialog"
aria-modal="true"
aria-labelledby="settings-modal-title"
onClick={handleInnerClick}
>
<PanelHeader
className="panel-modal__header"
title="Settings"
titleTag="h3"
titleProps={{ id: 'settings-modal-title' }}
actions={(
<button type="button" className="secondary" onClick={onClose}>
Close
</button>
)}
/>
<div className="settings-modal__body">
<nav className="settings-modal__sidebar" aria-label="Settings sections">
<ul>
{SECTIONS.map((section) => (
<li key={section.id}>
<button
type="button"
className={section.id === activeSection ? 'active' : ''}
onClick={() => setActiveSection(section.id)}
>
{section.label}
</button>
</li>
))}
</ul>
</nav>
<div className="settings-modal__content">
{activeSection === 'passkeys' ? renderPasskeysSection : null}
{activeSection === 'apiTokens' ? renderApiTokensSection : null}
{activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? (
<p>Select a settings section.</p>
) : null}
</div>
</div>
</div>
</div>
);
};
export default SettingsModal;