folders-move
This commit is contained in:
@@ -1,17 +1,158 @@
|
||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
import { CheckIcon, ChevronDownIcon, IconX } from '../ui/icons';
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: 'passkeys',
|
||||
label: 'Passkeys',
|
||||
},
|
||||
{
|
||||
id: 'capabilitySets',
|
||||
label: 'Capability sets',
|
||||
},
|
||||
{
|
||||
id: 'apiTokens',
|
||||
label: 'API tokens',
|
||||
},
|
||||
];
|
||||
|
||||
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 handlePointer = (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', handlePointer);
|
||||
document.addEventListener('touchstart', handlePointer, { passive: true });
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointer);
|
||||
document.removeEventListener('touchstart', handlePointer);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsModal = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -22,10 +163,21 @@ const SettingsModal = ({
|
||||
regeneratingId = null,
|
||||
capabilitySets = [],
|
||||
capabilitySetsLoading = false,
|
||||
creatingCapabilitySet = false,
|
||||
savingCapabilitySetId = null,
|
||||
deletingCapabilitySetId = null,
|
||||
supportsCapabilitySetLabels = false,
|
||||
capabilities = [],
|
||||
capabilitiesLoading = false,
|
||||
onRefresh,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
onRefreshCapabilitySets,
|
||||
onRefreshCapabilities,
|
||||
onCreate: onCreateToken,
|
||||
onDelete: onDeleteToken,
|
||||
onRegenerate: onRegenerateToken,
|
||||
onCreateCapabilitySet,
|
||||
onUpdateCapabilitySet,
|
||||
onDeleteCapabilitySet,
|
||||
createdToken = null,
|
||||
onDismissCreatedToken,
|
||||
passkeys = [],
|
||||
@@ -44,6 +196,15 @@ const SettingsModal = ({
|
||||
const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState('');
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
||||
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 handleBackdropClick = useCallback(() => {
|
||||
onClose?.();
|
||||
@@ -61,6 +222,15 @@ const SettingsModal = ({
|
||||
setNewTokenCapabilitySetId('');
|
||||
setFormError(null);
|
||||
setNewPasskeyNickname('');
|
||||
setNewCapabilitySetSlug('');
|
||||
setNewCapabilitySetLabel('');
|
||||
setNewCapabilitySetCapabilities([]);
|
||||
setCapabilitySetFormError(null);
|
||||
setEditingCapabilitySetId(null);
|
||||
setEditCapabilitySetSlug('');
|
||||
setEditCapabilitySetLabel('');
|
||||
setEditCapabilitySetCapabilities([]);
|
||||
setCapabilitySetEditError(null);
|
||||
}
|
||||
}, [open, defaultSection]);
|
||||
|
||||
@@ -134,6 +304,65 @@ const SettingsModal = ({
|
||||
[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 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 selectedTokenCapabilitySetCapabilities = useMemo(
|
||||
() => capabilitySetMap[newTokenCapabilitySetId]?.capabilities || [],
|
||||
[capabilitySetMap, newTokenCapabilitySetId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capabilitySetOptions.length) {
|
||||
setNewTokenCapabilitySetId('');
|
||||
@@ -150,6 +379,182 @@ const SettingsModal = ({
|
||||
setNewTokenCapabilitySetId(event.target.value);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
const handleCreateToken = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -181,7 +586,7 @@ const SettingsModal = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await onCreate?.({
|
||||
const result = await onCreateToken?.({
|
||||
label: normalizedLabel,
|
||||
expires_at: normalizedExpires,
|
||||
capability_set_id: selectedCapabilitySetId,
|
||||
@@ -199,7 +604,7 @@ const SettingsModal = ({
|
||||
newTokenCapabilitySetId,
|
||||
newTokenExpires,
|
||||
newTokenLabel,
|
||||
onCreate,
|
||||
onCreateToken,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -208,11 +613,342 @@ const SettingsModal = ({
|
||||
if (!token?.id) {
|
||||
return;
|
||||
}
|
||||
await onRegenerate?.(token.id);
|
||||
await onRegenerateToken?.(token.id);
|
||||
},
|
||||
[onRegenerate],
|
||||
[onRegenerateToken],
|
||||
);
|
||||
|
||||
const renderCapabilitySetsSection = useMemo(() => {
|
||||
const hasCapabilitySets = Array.isArray(capabilitySets) && capabilitySets.length > 0;
|
||||
const columnCount = supportsCapabilitySetLabels ? 6 : 5;
|
||||
|
||||
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>
|
||||
);
|
||||
}, [
|
||||
capabilitySetEditError,
|
||||
capabilitySetFormError,
|
||||
capabilitySelectionOptions,
|
||||
capabilitiesLoading,
|
||||
capabilitySets,
|
||||
capabilitySetsLoading,
|
||||
creatingCapabilitySet,
|
||||
deletingCapabilitySetId,
|
||||
editCapabilitySetCapabilities,
|
||||
editCapabilitySetLabel,
|
||||
editCapabilitySetSlug,
|
||||
editingCapabilitySetId,
|
||||
handleCapabilitySetsRefresh,
|
||||
handleAddCapabilityToEditSet,
|
||||
handleAddCapabilityToNewSet,
|
||||
handleCancelEditCapabilitySet,
|
||||
handleCreateCapabilitySetSubmit,
|
||||
handleDeleteCapabilitySet,
|
||||
handleRemoveCapabilityFromEditSet,
|
||||
handleRemoveCapabilityFromNewSet,
|
||||
handleStartEditCapabilitySet,
|
||||
handleUpdateCapabilitySetSubmit,
|
||||
newCapabilitySetCapabilities,
|
||||
newCapabilitySetLabel,
|
||||
newCapabilitySetSlug,
|
||||
formatCapabilityLabel,
|
||||
sortCapabilityValues,
|
||||
savingCapabilitySetId,
|
||||
supportsCapabilitySetLabels,
|
||||
]);
|
||||
|
||||
const renderApiTokensSection = useMemo(() => {
|
||||
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
||||
|
||||
@@ -271,7 +1007,7 @@ const SettingsModal = ({
|
||||
onChange={(event) => setNewTokenExpires(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-form__field">
|
||||
<div className="settings-form__field settings-form__field--full">
|
||||
<label htmlFor="api-token-capability-set">Capability set</label>
|
||||
<select
|
||||
id="api-token-capability-set"
|
||||
@@ -298,12 +1034,15 @@ const SettingsModal = ({
|
||||
{!capabilitySetsLoading && !capabilitySetOptions.length ? (
|
||||
<small>No capability sets available.</small>
|
||||
) : null}
|
||||
{!capabilitySetsLoading
|
||||
&& capabilitySetMap[newTokenCapabilitySetId]?.capabilities?.length ? (
|
||||
<small>
|
||||
Includes: {capabilitySetMap[newTokenCapabilitySetId].capabilities.join(', ')}
|
||||
</small>
|
||||
) : null}
|
||||
{!capabilitySetsLoading && selectedTokenCapabilitySetCapabilities.length ? (
|
||||
<div className="settings-capability-picker__chips settings-capability-picker__chips--inline">
|
||||
{selectedTokenCapabilitySetCapabilities.map((value) => (
|
||||
<span key={value} className="badge tag-chip">
|
||||
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="settings-form__actions">
|
||||
<button
|
||||
@@ -346,9 +1085,11 @@ const SettingsModal = ({
|
||||
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 capabilityList = sortCapabilityValues(
|
||||
Array.isArray(token?.capabilities) && token.capabilities.length
|
||||
? token.capabilities
|
||||
: selectedSet?.capabilities || [],
|
||||
);
|
||||
const capabilitySetLabel = selectedSet?.label
|
||||
|| selectedSet?.slug
|
||||
|| token.capability_set_id
|
||||
@@ -367,9 +1108,9 @@ const SettingsModal = ({
|
||||
<small>Loading capability sets…</small>
|
||||
) : null}
|
||||
{!capabilitySetsLoading && capabilityList.length ? (
|
||||
<div className="settings-capabilities">
|
||||
<small>Includes: {capabilityList.join(', ')}</small>
|
||||
</div>
|
||||
<small className="settings-capabilities-summary">
|
||||
{capabilityList.length} capabilities
|
||||
</small>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="settings-table__actions">
|
||||
@@ -390,7 +1131,7 @@ const SettingsModal = ({
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => onDelete?.(token.id)}
|
||||
onClick={() => onDeleteToken?.(token.id)}
|
||||
disabled={
|
||||
deletingId === token.id || regeneratingId === token.id
|
||||
}
|
||||
@@ -422,12 +1163,15 @@ const SettingsModal = ({
|
||||
capabilitySetOptions,
|
||||
capabilitySetMap,
|
||||
capabilitySetsLoading,
|
||||
sortCapabilityValues,
|
||||
selectedTokenCapabilitySetCapabilities,
|
||||
formatCapabilityLabel,
|
||||
formatDateTime,
|
||||
handleCopyToken,
|
||||
handleCreateToken,
|
||||
handleRegenerateToken,
|
||||
handleRefresh,
|
||||
onDelete,
|
||||
onDeleteToken,
|
||||
handleDismissSecret,
|
||||
handleNewCapabilitySetChange,
|
||||
]);
|
||||
@@ -594,8 +1338,11 @@ const SettingsModal = ({
|
||||
</nav>
|
||||
<div className="settings-modal__content">
|
||||
{activeSection === 'passkeys' ? renderPasskeysSection : null}
|
||||
{activeSection === 'capabilitySets' ? renderCapabilitySetsSection : null}
|
||||
{activeSection === 'apiTokens' ? renderApiTokensSection : null}
|
||||
{activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? (
|
||||
{activeSection !== 'passkeys'
|
||||
&& activeSection !== 'capabilitySets'
|
||||
&& activeSection !== 'apiTokens' ? (
|
||||
<p>Select a settings section.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user