609 lines
20 KiB
React
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;
|