This commit is contained in:
2025-11-06 11:41:28 +01:00
parent ebed18afef
commit 28b9112781
7 changed files with 1388 additions and 1293 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
import React, {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { CheckIcon, ChevronDownIcon } from '../../ui/icons';
const CapabilityDropdown = ({
id,
options = [],
selectedValues = [],
onSelect,
onDeselect,
formatLabel,
disabled = false,
loading = false,
summaryLabel = 'capabilities',
}) => {
const anchorRef = useRef(null);
const menuRef = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const close = useCallback(() => {
setIsOpen(false);
}, []);
const toggle = useCallback(() => {
if (disabled) {
return;
}
setIsOpen((previous) => !previous);
}, [disabled]);
useEffect(() => {
if (!isOpen) {
return undefined;
}
const handlePointerEvent = (event) => {
if (anchorRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) {
return;
}
close();
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
close();
}
};
document.addEventListener('mousedown', handlePointerEvent);
document.addEventListener('touchstart', handlePointerEvent, { passive: true });
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerEvent);
document.removeEventListener('touchstart', handlePointerEvent);
document.removeEventListener('keydown', handleKeyDown);
};
}, [close, isOpen]);
const handleOptionClick = useCallback((value) => {
if (selectedValues.includes(value)) {
onDeselect?.(value);
} else {
onSelect?.(value);
}
}, [onDeselect, onSelect, selectedValues]);
const total = options.length;
const selectedCount = selectedValues.length;
const summaryText = total
? `${selectedCount}/${total} ${summaryLabel} enabled`
: selectedCount
? `${selectedCount} ${summaryLabel} selected`
: loading
? `Loading ${summaryLabel}`
: `No ${summaryLabel}`;
const buttonText = selectedCount || loading || total
? summaryText
: `Select ${summaryLabel}`;
const emptyMessage = loading
? `Loading ${summaryLabel}`
: `No ${summaryLabel} available.`;
const isDisabled = disabled || (total === 0 && !selectedCount) || loading;
return (
<div className="capability-dropdown">
<button
type="button"
className="capability-dropdown__trigger"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={id ? `${id}-menu` : undefined}
onClick={toggle}
disabled={isDisabled}
ref={anchorRef}
>
<span>{buttonText}</span>
<ChevronDownIcon className="capability-dropdown__chevron" aria-hidden="true" />
</button>
{isOpen ? (
<div
id={id ? `${id}-menu` : undefined}
role="menu"
ref={menuRef}
className="menu menu--floating capability-dropdown__menu"
>
{total ? (
options.map((option) => {
const value = option?.value ?? option?.id ?? option;
const label = typeof formatLabel === 'function'
? formatLabel(value)
: option?.label || String(value);
const selected = selectedValues.includes(value);
return (
<button
key={value}
type="button"
role="menuitemcheckbox"
aria-checked={selected}
className={`menu__item capability-dropdown__option${selected ? ' is-selected' : ''}`}
onClick={() => handleOptionClick(value)}
>
<span className="capability-dropdown__option-icon">
{selected ? <CheckIcon className="icon-inline" aria-hidden="true" /> : null}
</span>
<span className="capability-dropdown__option-label">{label}</span>
</button>
);
})
) : (
<div className="capability-dropdown__empty">{emptyMessage}</div>
)}
</div>
) : null}
</div>
);
};
export default CapabilityDropdown;
@@ -0,0 +1,407 @@
import React, {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
const formatDateTime = (value) => {
if (!value) {
return '—';
}
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) {
return value;
}
return timestamp.toLocaleString();
};
const ApiTokensSection = ({
tokens = [],
loading = false,
creating = false,
deletingId = null,
regeneratingId = null,
createdToken = null,
capabilitySets = [],
capabilitySetsLoading = false,
capabilities = [],
capabilitiesLoading = false,
onRefresh,
onCreate,
onDelete,
onRegenerate,
onDismissCreatedToken,
onRefreshCapabilitySets,
onRefreshCapabilities,
}) => {
const [newTokenLabel, setNewTokenLabel] = useState('');
const [newTokenExpires, setNewTokenExpires] = useState('');
const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState('');
const [formError, setFormError] = useState(null);
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],
);
const capabilitySelectionOptions = useMemo(() => (
Array.isArray(capabilities)
? capabilities.map((capability) => {
if (typeof capability !== 'string') {
return { value: capability, label: String(capability) };
}
const [namespace, action] = capability.split(':');
if (!namespace || !action) {
return { value: capability, label: capability };
}
const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`;
const formattedAction = action.replace(/_/g, ' ');
return {
value: capability,
label: `${formattedNamespace}: ${formattedAction}`,
};
})
: []
), [capabilities]);
const capabilityLabelMap = useMemo(() => {
const map = new Map();
capabilitySelectionOptions.forEach(({ value, label }) => {
map.set(value, label || String(value));
});
return map;
}, [capabilitySelectionOptions]);
const formatCapabilityLabel = useCallback((value) => (
capabilityLabelMap.get(value) || String(value)
), [capabilityLabelMap]);
useEffect(() => {
if (!capabilitySetOptions.length) {
setNewTokenCapabilitySetId('');
return;
}
if (!newTokenCapabilitySetId
|| !capabilitySetOptions.some((option) => option.value === newTokenCapabilitySetId)) {
setNewTokenCapabilitySetId(capabilitySetOptions[0].value);
}
}, [capabilitySetOptions, newTokenCapabilitySetId]);
const selectedTokenCapabilitySetCapabilities = useMemo(
() => capabilitySetMap[newTokenCapabilitySetId]?.capabilities || [],
[capabilitySetMap, newTokenCapabilitySetId],
);
const handleRefresh = useCallback(() => {
if (onRefresh) {
onRefresh();
}
onRefreshCapabilitySets?.();
onRefreshCapabilities?.();
}, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]);
const handleCopyToken = useCallback(() => {
if (!createdToken) {
return;
}
if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(createdToken).catch(() => {});
}
}, [createdToken]);
const handleDismissSecret = useCallback(() => {
onDismissCreatedToken?.();
}, [onDismissCreatedToken]);
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],
);
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 settings-form__field--full">
<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.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 yet.</small>
) : null}
</div>
<div className="settings-form__field settings-form__field--full">
{selectedTokenCapabilitySetCapabilities.length ? (
<div className="settings-capability-list">
{selectedTokenCapabilitySetCapabilities.map((value) => (
<span key={value} className="settings-capability-list__item badge tag-chip">
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
</span>
))}
</div>
) : (
<span className="settings-capability-picker__placeholder">No capabilities selected.</span>
)}
{capabilitiesLoading ? (
<small>Loading capabilities</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 && !tokens.length ? (
<p className="settings-empty">Loading tokens</p>
) : null}
{!loading && !tokens.length ? (
<p className="settings-empty">No API tokens yet.</p>
) : null}
{tokens.length ? (
<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-capability-list settings-capability-list--compact">
{capabilityList.map((value) => (
<span key={value} className="settings-capability-list__item badge tag-chip">
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
</span>
))}
</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>
);
};
export const API_TOKENS_SECTION = {
id: 'apiTokens',
label: 'API tokens',
component: ApiTokensSection,
};
export default ApiTokensSection;
@@ -0,0 +1,582 @@
import React, {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { IconX } from '../../ui/icons';
import CapabilityDropdown from '../components/CapabilityDropdown';
const CapabilitySetsSection = ({
capabilitySets = [],
capabilitySetsLoading = false,
creatingCapabilitySet = false,
savingCapabilitySetId = null,
deletingCapabilitySetId = null,
supportsCapabilitySetLabels = false,
capabilities = [],
capabilitiesLoading = false,
onRefreshCapabilitySets,
onRefreshCapabilities,
onRefresh,
onCreateCapabilitySet,
onUpdateCapabilitySet,
onDeleteCapabilitySet,
}) => {
const [newCapabilitySetSlug, setNewCapabilitySetSlug] = useState('');
const [newCapabilitySetLabel, setNewCapabilitySetLabel] = useState('');
const [newCapabilitySetCapabilities, setNewCapabilitySetCapabilities] = useState([]);
const [capabilitySetFormError, setCapabilitySetFormError] = useState(null);
const [editingCapabilitySetId, setEditingCapabilitySetId] = useState(null);
const [editCapabilitySetSlug, setEditCapabilitySetSlug] = useState('');
const [editCapabilitySetLabel, setEditCapabilitySetLabel] = useState('');
const [editCapabilitySetCapabilities, setEditCapabilitySetCapabilities] = useState([]);
const [capabilitySetEditError, setCapabilitySetEditError] = useState(null);
const capabilitySelectionOptions = useMemo(() => (
Array.isArray(capabilities)
? capabilities.map((capability) => {
if (typeof capability !== 'string') {
return { value: capability, label: String(capability) };
}
const [namespace, action] = capability.split(':');
if (!namespace || !action) {
return { value: capability, label: capability };
}
const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`;
const formattedAction = action.replace(/_/g, ' ');
return {
value: capability,
label: `${formattedNamespace}: ${formattedAction}`,
};
})
: []
), [capabilities]);
const capabilityLabelMap = useMemo(() => {
const map = new Map();
capabilitySelectionOptions.forEach(({ value, label }) => {
map.set(value, label || String(value));
});
return map;
}, [capabilitySelectionOptions]);
const capabilityOrder = useMemo(() => {
const order = new Map();
capabilitySelectionOptions.forEach((option, index) => {
order.set(option.value, index);
});
return order;
}, [capabilitySelectionOptions]);
const sortCapabilityValues = useCallback((values) => {
if (!Array.isArray(values)) {
return [];
}
return [...values].sort((a, b) => {
const indexA = capabilityOrder.has(a) ? capabilityOrder.get(a) : Number.MAX_SAFE_INTEGER;
const indexB = capabilityOrder.has(b) ? capabilityOrder.get(b) : Number.MAX_SAFE_INTEGER;
if (indexA === indexB) {
return String(a).localeCompare(String(b));
}
return indexA - indexB;
});
}, [capabilityOrder]);
const formatCapabilityLabel = useCallback((value) => (
capabilityLabelMap.get(value) || String(value)
), [capabilityLabelMap]);
const capabilitySetOptions = useMemo(
() => capabilitySets.map((set) => ({
value: set.id,
label: set.label || set.slug || set.id,
capabilities: Array.isArray(set.capabilities) ? set.capabilities : [],
isSystem: Boolean(set?.is_system),
version: typeof set?.cap_version === 'number' ? set.cap_version : null,
})),
[capabilitySets],
);
const hasCapabilitySets = capabilitySetOptions.length > 0;
const columnCount = supportsCapabilitySetLabels ? 6 : 5;
const handleCapabilitySetsRefresh = useCallback(() => {
if (onRefreshCapabilitySets) {
onRefreshCapabilitySets();
} else {
onRefresh?.();
}
onRefreshCapabilities?.();
}, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]);
const handleAddCapabilityToNewSet = useCallback((option) => {
const value = option?.value ?? option?.id ?? option;
if (!value) {
return;
}
setCapabilitySetFormError(null);
setNewCapabilitySetCapabilities((previous) => {
if (previous.includes(value)) {
return previous;
}
return sortCapabilityValues([...previous, value]);
});
}, [sortCapabilityValues]);
const handleRemoveCapabilityFromNewSet = useCallback((value) => {
setCapabilitySetFormError(null);
setNewCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value));
}, []);
const handleCreateCapabilitySetSubmit = useCallback(
async (event) => {
event.preventDefault();
setCapabilitySetFormError(null);
if (!Array.isArray(newCapabilitySetCapabilities) || newCapabilitySetCapabilities.length === 0) {
setCapabilitySetFormError('Select at least one capability.');
return;
}
if (!capabilitySelectionOptions.length) {
setCapabilitySetFormError('Capabilities are still loading.');
return;
}
const payload = {
slug: newCapabilitySetSlug,
label: newCapabilitySetLabel,
capabilities: sortCapabilityValues(newCapabilitySetCapabilities),
};
const result = await onCreateCapabilitySet?.(payload);
if (result === false) {
setCapabilitySetFormError('Failed to create capability set.');
return;
}
setNewCapabilitySetSlug('');
setNewCapabilitySetLabel('');
setNewCapabilitySetCapabilities([]);
setCapabilitySetFormError(null);
},
[
capabilitySelectionOptions,
newCapabilitySetCapabilities,
newCapabilitySetLabel,
newCapabilitySetSlug,
onCreateCapabilitySet,
sortCapabilityValues,
],
);
const handleStartEditCapabilitySet = useCallback((capabilitySet) => {
if (!capabilitySet) {
return;
}
setCapabilitySetEditError(null);
setEditingCapabilitySetId(capabilitySet.id);
setEditCapabilitySetSlug(capabilitySet.slug || '');
setEditCapabilitySetLabel(capabilitySet.label || '');
setEditCapabilitySetCapabilities(
sortCapabilityValues(Array.isArray(capabilitySet.capabilities) ? capabilitySet.capabilities : []),
);
}, [sortCapabilityValues]);
const handleCancelEditCapabilitySet = useCallback(() => {
setEditingCapabilitySetId(null);
setEditCapabilitySetSlug('');
setEditCapabilitySetLabel('');
setEditCapabilitySetCapabilities([]);
setCapabilitySetEditError(null);
}, []);
const handleAddCapabilityToEditSet = useCallback((option) => {
const value = option?.value ?? option?.id ?? option;
if (!value) {
return;
}
setCapabilitySetEditError(null);
setEditCapabilitySetCapabilities((previous) => {
if (previous.includes(value)) {
return previous;
}
return sortCapabilityValues([...previous, value]);
});
}, [sortCapabilityValues]);
const handleRemoveCapabilityFromEditSet = useCallback((value) => {
setCapabilitySetEditError(null);
setEditCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value));
}, []);
const handleUpdateCapabilitySetSubmit = useCallback(
async (event) => {
event.preventDefault();
if (!editingCapabilitySetId) {
return;
}
if (!Array.isArray(editCapabilitySetCapabilities) || editCapabilitySetCapabilities.length === 0) {
setCapabilitySetEditError('Select at least one capability.');
return;
}
const payload = {
slug: editCapabilitySetSlug,
label: editCapabilitySetLabel,
capabilities: sortCapabilityValues(editCapabilitySetCapabilities),
};
const result = await onUpdateCapabilitySet?.(editingCapabilitySetId, payload);
if (result === false) {
setCapabilitySetEditError('Failed to update capability set.');
return;
}
handleCancelEditCapabilitySet();
},
[
editCapabilitySetCapabilities,
editCapabilitySetLabel,
editCapabilitySetSlug,
editingCapabilitySetId,
handleCancelEditCapabilitySet,
onUpdateCapabilitySet,
sortCapabilityValues,
],
);
const handleDeleteCapabilitySet = useCallback(
async (capabilitySet) => {
if (!capabilitySet?.id) {
return;
}
const displayName = capabilitySet.slug || capabilitySet.label || capabilitySet.id;
const confirmed = window.confirm(`Delete capability set "${displayName}"?`);
if (!confirmed) {
return;
}
const result = await onDeleteCapabilitySet?.(capabilitySet.id);
if (result === false) {
setCapabilitySetEditError('Failed to delete capability set.');
}
},
[onDeleteCapabilitySet],
);
useEffect(() => {
if (!editingCapabilitySetId) {
return;
}
const exists = capabilitySets.some((set) => set.id === editingCapabilitySetId);
if (!exists) {
handleCancelEditCapabilitySet();
}
}, [capabilitySets, editingCapabilitySetId, handleCancelEditCapabilitySet]);
return (
<div className="settings-section">
<div className="settings-actions">
<button
type="button"
className="secondary"
onClick={handleCapabilitySetsRefresh}
disabled={capabilitySetsLoading}
>
{capabilitySetsLoading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
<p>
Capability sets bundle permissions that you can assign to API tokens and user memberships.
</p>
<form className="settings-form" onSubmit={handleCreateCapabilitySetSubmit}>
<div className="settings-form__field">
<label htmlFor="capability-set-slug">Slug</label>
<input
id="capability-set-slug"
type="text"
value={newCapabilitySetSlug}
onChange={(event) => setNewCapabilitySetSlug(event.target.value)}
placeholder="e.g. api_readonly"
disabled={creatingCapabilitySet || capabilitySetsLoading}
/>
<small>Leave blank to generate a slug automatically.</small>
</div>
{supportsCapabilitySetLabels ? (
<div className="settings-form__field">
<label htmlFor="capability-set-label">Label</label>
<input
id="capability-set-label"
type="text"
value={newCapabilitySetLabel}
onChange={(event) => setNewCapabilitySetLabel(event.target.value)}
placeholder="Friendly name (optional)"
disabled={creatingCapabilitySet || capabilitySetsLoading}
/>
</div>
) : null}
<div className="settings-form__field settings-form__field--full">
<label htmlFor="capability-set-capabilities">Capabilities</label>
<div className="settings-capability-picker" id="capability-set-capabilities">
<CapabilityDropdown
id="capability-set-capabilities"
options={capabilitySelectionOptions}
selectedValues={newCapabilitySetCapabilities}
onSelect={handleAddCapabilityToNewSet}
onDeselect={handleRemoveCapabilityFromNewSet}
formatLabel={formatCapabilityLabel}
disabled={
creatingCapabilitySet
|| capabilitySetsLoading
|| capabilitiesLoading
|| !capabilitySelectionOptions.length
}
loading={capabilitiesLoading}
summaryLabel="capabilities"
/>
{newCapabilitySetCapabilities.length ? (
<div className="settings-capability-picker__chips">
{newCapabilitySetCapabilities.map((value) => (
<span key={value} className="badge tag-chip">
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
<button
type="button"
className="tag-chip__remove"
onClick={() => handleRemoveCapabilityFromNewSet(value)}
aria-label={`Remove capability ${formatCapabilityLabel(value)}`}
>
<IconX className="icon-inline" aria-hidden="true" />
</button>
</span>
))}
</div>
) : (
<span className="settings-capability-picker__placeholder">No capabilities selected.</span>
)}
{capabilitiesLoading ? (
<small>Loading capabilities</small>
) : null}
{!capabilitiesLoading && !capabilitySelectionOptions.length ? (
<small>No capabilities available.</small>
) : null}
</div>
</div>
<div className="settings-form__actions">
<button
type="submit"
disabled={
creatingCapabilitySet
|| capabilitySetsLoading
|| capabilitiesLoading
|| !capabilitySelectionOptions.length
}
>
{creatingCapabilitySet ? 'Creating…' : 'Create capability set'}
</button>
</div>
</form>
{capabilitySetFormError ? (
<p className="settings-form__error">{capabilitySetFormError}</p>
) : null}
{capabilitySetsLoading && !hasCapabilitySets ? (
<p className="settings-empty">Loading capability sets</p>
) : null}
{!capabilitySetsLoading && !hasCapabilitySets ? (
<p className="settings-empty">No capability sets yet.</p>
) : null}
{hasCapabilitySets ? (
<table className="settings-table">
<thead>
<tr>
<th scope="col">Slug</th>
{supportsCapabilitySetLabels ? <th scope="col">Label</th> : null}
<th scope="col">Capabilities</th>
<th scope="col">System</th>
<th scope="col">Version</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{capabilitySets.map((set) => {
const isSystem = Boolean(set?.is_system);
const isEditing = editingCapabilitySetId === set.id;
const capabilityList = sortCapabilityValues(
Array.isArray(set?.capabilities) ? set.capabilities : [],
);
const saving = savingCapabilitySetId === set.id;
const deleting = deletingCapabilitySetId === set.id;
return (
<React.Fragment key={set.id}>
<tr className={isSystem ? 'is-system' : undefined}>
<td>{set.slug || '—'}</td>
{supportsCapabilitySetLabels ? (
<td>{set.label || '—'}</td>
) : null}
<td>
{capabilityList.length
? capabilityList.map((value) => formatCapabilityLabel(value)).join(', ')
: '—'}
</td>
<td>{isSystem ? 'Yes' : 'No'}</td>
<td>{typeof set.cap_version === 'number' ? set.cap_version : '—'}</td>
<td className="settings-table__actions">
{isSystem ? (
<span className="settings-status">System set</span>
) : (
<>
<button
type="button"
className="secondary"
onClick={() => handleStartEditCapabilitySet(set)}
disabled={
saving
|| deleting
|| capabilitySetsLoading
}
>
{isEditing ? 'Editing…' : 'Edit'}
</button>
<button
type="button"
className="danger"
onClick={() => handleDeleteCapabilitySet(set)}
disabled={deleting || saving || capabilitySetsLoading}
>
{deleting ? 'Deleting…' : 'Delete'}
</button>
</>
)}
</td>
</tr>
{isEditing ? (
<tr className="settings-table__edit-row">
<td colSpan={columnCount}>
<form className="settings-form" onSubmit={handleUpdateCapabilitySetSubmit}>
<div className="settings-form__field">
<label htmlFor="edit-capability-set-slug">Slug</label>
<input
id="edit-capability-set-slug"
type="text"
value={editCapabilitySetSlug}
onChange={(event) => setEditCapabilitySetSlug(event.target.value)}
disabled={saving || capabilitySetsLoading}
/>
</div>
{supportsCapabilitySetLabels ? (
<div className="settings-form__field">
<label htmlFor="edit-capability-set-label">Label</label>
<input
id="edit-capability-set-label"
type="text"
value={editCapabilitySetLabel}
onChange={(event) => setEditCapabilitySetLabel(event.target.value)}
disabled={saving || capabilitySetsLoading}
/>
</div>
) : null}
<div className="settings-form__field settings-form__field--full">
<label htmlFor={`edit-capability-set-${set.id}-capabilities`}>
Capabilities
</label>
<div
className="settings-capability-picker"
id={`edit-capability-set-${set.id}-capabilities`}
>
<CapabilityDropdown
id={`edit-capability-set-${set.id}-capabilities`}
options={capabilitySelectionOptions}
selectedValues={editCapabilitySetCapabilities}
onSelect={handleAddCapabilityToEditSet}
onDeselect={handleRemoveCapabilityFromEditSet}
formatLabel={formatCapabilityLabel}
disabled={
saving
|| capabilitySetsLoading
|| capabilitiesLoading
|| !capabilitySelectionOptions.length
}
loading={capabilitiesLoading}
summaryLabel="capabilities"
/>
{editCapabilitySetCapabilities.length ? (
<div className="settings-capability-picker__chips">
{editCapabilitySetCapabilities.map((value) => (
<span key={value} className="badge tag-chip">
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
<button
type="button"
className="tag-chip__remove"
onClick={() => handleRemoveCapabilityFromEditSet(value)}
aria-label={`Remove capability ${formatCapabilityLabel(value)}`}
disabled={saving}
>
<IconX className="icon-inline" aria-hidden="true" />
</button>
</span>
))}
</div>
) : (
<span className="settings-capability-picker__placeholder">
No capabilities selected.
</span>
)}
{capabilitiesLoading ? (
<small>Loading capabilities</small>
) : null}
{!capabilitiesLoading && !capabilitySelectionOptions.length ? (
<small>No capabilities available.</small>
) : null}
</div>
</div>
<div className="settings-form__actions">
<button
type="submit"
disabled={
saving
|| capabilitySetsLoading
|| capabilitiesLoading
|| !capabilitySelectionOptions.length
}
>
{saving ? 'Saving…' : 'Save changes'}
</button>
<button
type="button"
className="secondary"
onClick={handleCancelEditCapabilitySet}
disabled={saving}
>
Cancel
</button>
</div>
</form>
{capabilitySetEditError ? (
<p className="settings-form__error">{capabilitySetEditError}</p>
) : null}
</td>
</tr>
) : null}
</React.Fragment>
);
})}
</tbody>
</table>
) : null}
</div>
);
};
export const CAPABILITY_SETS_SECTION = {
id: 'capabilitySets',
label: 'Capability sets',
component: CapabilitySetsSection,
};
export default CapabilitySetsSection;
@@ -0,0 +1,172 @@
import React, {
useCallback,
useMemo,
useState,
} from 'react';
const formatDateTime = (value) => {
if (!value) {
return '—';
}
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) {
return value;
}
return timestamp.toLocaleString();
};
const PasskeysSection = ({
passkeys = [],
passkeysSupported = null,
passkeysLoading = false,
registeringPasskey = false,
revokingPasskeyId = null,
onRefreshPasskeys,
onRegisterPasskey,
onRevokePasskey,
}) => {
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
const hasPasskeys = useMemo(() => Array.isArray(passkeys) && passkeys.length > 0, [passkeys]);
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],
);
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>
);
};
export const PASSKEYS_SECTION = {
id: 'passkeys',
label: 'Passkeys',
component: PasskeysSection,
};
export default PasskeysSection;
+15
View File
@@ -0,0 +1,15 @@
import PasskeysSection, { PASSKEYS_SECTION } from './PasskeysSection';
import ApiTokensSection, { API_TOKENS_SECTION } from './ApiTokensSection';
import CapabilitySetsSection, { CAPABILITY_SETS_SECTION } from './CapabilitySetsSection';
export const DEFAULT_SETTINGS_SECTIONS = [
PASSKEYS_SECTION,
API_TOKENS_SECTION,
CAPABILITY_SETS_SECTION,
];
export {
PasskeysSection,
ApiTokensSection,
CapabilitySetsSection,
};