settings
This commit is contained in:
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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
+18
-1
@@ -3723,7 +3723,7 @@ form.inline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-modal__sidebar button.active {
|
.settings-modal__sidebar button.active {
|
||||||
background: var(--surface-soft);
|
background: var(--accent-soft);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3906,6 +3906,23 @@ fieldset.settings-form__field legend {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-capability-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capability-list--compact {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capability-list__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-choice {
|
.settings-choice {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user