folders-move

This commit is contained in:
2025-11-06 00:58:17 +01:00
parent e9c5f27b17
commit a16f1501d3
12 changed files with 1525 additions and 89 deletions
+4
View File
@@ -2768,6 +2768,8 @@ export const createDesktopSurface = ({
onBulkCorrespondentAdd, onBulkCorrespondentAdd,
onBulkCorrespondentRemove, onBulkCorrespondentRemove,
onBulkReanalyze, onBulkReanalyze,
folderOptions,
onMoveDocumentsToFolder,
} = workspaceProps; } = workspaceProps;
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
@@ -2803,6 +2805,8 @@ export const createDesktopSurface = ({
onBulkReanalyze={onBulkReanalyze} onBulkReanalyze={onBulkReanalyze}
onDeleteSelection={onDeleteSelection} onDeleteSelection={onDeleteSelection}
onClearSelection={onClearSelection} onClearSelection={onClearSelection}
folderOptions={folderOptions}
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
/> />
) )
: null; : null;
+8
View File
@@ -4575,6 +4575,8 @@ const AppLayout = () => {
onBulkCorrespondentAdd: handleBulkCorrespondentAdd, onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
onBulkCorrespondentRemove: handleBulkCorrespondentRemove, onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
onBulkReanalyze: handleBulkSelectionReanalyze, onBulkReanalyze: handleBulkSelectionReanalyze,
folderOptions,
onMoveDocumentsToFolder: moveDocumentsToFolder,
}), }),
[ [
activeCorrespondentFilters, activeCorrespondentFilters,
@@ -4623,6 +4625,8 @@ const AppLayout = () => {
handleBulkCorrespondentAdd, handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove, handleBulkCorrespondentRemove,
handleBulkSelectionReanalyze, handleBulkSelectionReanalyze,
folderOptions,
moveDocumentsToFolder,
], ],
); );
@@ -4882,6 +4886,8 @@ const AppLayout = () => {
onBulkCorrespondentAdd: handleBulkCorrespondentAdd, onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
onBulkCorrespondentRemove: handleBulkCorrespondentRemove, onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
onBulkReanalyze: handleBulkSelectionReanalyze, onBulkReanalyze: handleBulkSelectionReanalyze,
folderOptions,
onMoveDocumentsToFolder: moveDocumentsToFolder,
}), }),
[ [
documents, documents,
@@ -4922,6 +4928,8 @@ const AppLayout = () => {
handleBulkCorrespondentAdd, handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove, handleBulkCorrespondentRemove,
handleBulkSelectionReanalyze, handleBulkSelectionReanalyze,
folderOptions,
moveDocumentsToFolder,
], ],
); );
+49 -18
View File
@@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom';
import SettingsModal from '../settings/SettingsModal'; import SettingsModal from '../settings/SettingsModal';
import { useAppShell } from '../appShellContext'; import { useAppShell } from '../appShellContext';
import useApiTokens from '../settings/useApiTokens'; import useApiTokens from '../settings/useApiTokens';
import useCapabilitySets from '../settings/useCapabilitySets';
import useCapabilities from '../settings/useCapabilities';
import { api } from './appState'; import { api } from './appState';
const SettingsRoute = () => { const SettingsRoute = () => {
@@ -23,31 +25,49 @@ const SettingsRoute = () => {
const { const {
tokens, tokens,
loading, loading: tokensLoading,
creating, creating: creatingToken,
deletingId, deletingId,
regeneratingId, regeneratingId,
createdSecret, createdSecret,
refresh, refresh: refreshTokens,
refreshCapabilitySets, create: createToken,
create, revoke: revokeToken,
revoke, regenerate: regenerateToken,
regenerate,
dismissSecret, dismissSecret,
capabilitySets,
capabilitySetsLoading,
} = useApiTokens({ api, token, notifyApiError, setStatusMessage }); } = useApiTokens({ api, token, notifyApiError, setStatusMessage });
const {
capabilitySets,
capabilitySetsLoading,
creatingCapabilitySet,
savingCapabilitySetId,
deletingCapabilitySetId,
supportsCapabilitySetLabels,
refreshCapabilitySets,
createCapabilitySet,
updateCapabilitySet,
deleteCapabilitySet,
} = useCapabilitySets({ api, token, notifyApiError, setStatusMessage });
const {
capabilities,
capabilitiesLoading,
refreshCapabilities,
} = useCapabilities({ api, notifyApiError, token });
useEffect(() => { useEffect(() => {
refresh(); refreshTokens();
refreshCapabilitySets(); refreshCapabilitySets();
refreshCapabilities();
refreshPasskeys(); refreshPasskeys();
}, [refresh, refreshCapabilitySets, refreshPasskeys]); }, [refreshTokens, refreshCapabilitySets, refreshCapabilities, refreshPasskeys]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
refresh(); refreshTokens();
refreshCapabilitySets(); refreshCapabilitySets();
}, [refresh, refreshCapabilitySets]); refreshCapabilities();
}, [refreshTokens, refreshCapabilitySets, refreshCapabilities]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
dismissSecret(); dismissSecret();
@@ -74,18 +94,29 @@ const SettingsRoute = () => {
open open
onClose={handleClose} onClose={handleClose}
tokens={tokens} tokens={tokens}
loading={loading} loading={tokensLoading}
creating={creating} creating={creatingToken}
deletingId={deletingId} deletingId={deletingId}
regeneratingId={regeneratingId} regeneratingId={regeneratingId}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onCreate={create} onCreate={createToken}
onDelete={revoke} onDelete={revokeToken}
onRegenerate={regenerate} onRegenerate={regenerateToken}
createdToken={createdSecret} createdToken={createdSecret}
onDismissCreatedToken={dismissSecret} onDismissCreatedToken={dismissSecret}
capabilitySets={capabilitySets} capabilitySets={capabilitySets}
capabilitySetsLoading={capabilitySetsLoading} capabilitySetsLoading={capabilitySetsLoading}
creatingCapabilitySet={creatingCapabilitySet}
savingCapabilitySetId={savingCapabilitySetId}
deletingCapabilitySetId={deletingCapabilitySetId}
supportsCapabilitySetLabels={supportsCapabilitySetLabels}
onRefreshCapabilitySets={refreshCapabilitySets}
capabilities={capabilities}
capabilitiesLoading={capabilitiesLoading}
onRefreshCapabilities={refreshCapabilities}
onCreateCapabilitySet={createCapabilitySet}
onUpdateCapabilitySet={updateCapabilitySet}
onDeleteCapabilitySet={deleteCapabilitySet}
passkeys={passkeys} passkeys={passkeys}
passkeysSupported={passkeysSupported} passkeysSupported={passkeysSupported}
passkeysLoading={passkeysLoading} passkeysLoading={passkeysLoading}
@@ -844,6 +844,8 @@ export const createDocumentsSurface = ({
onBulkCorrespondentAdd, onBulkCorrespondentAdd,
onBulkCorrespondentRemove, onBulkCorrespondentRemove,
onBulkReanalyze, onBulkReanalyze,
folderOptions,
onMoveDocumentsToFolder,
} = tableProps; } = tableProps;
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
@@ -879,6 +881,8 @@ export const createDocumentsSurface = ({
onBulkReanalyze={onBulkReanalyze} onBulkReanalyze={onBulkReanalyze}
onDeleteSelection={onDeleteSelection} onDeleteSelection={onDeleteSelection}
onClearSelection={onClearSelection} onClearSelection={onClearSelection}
folderOptions={folderOptions}
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
/> />
) )
: null; : null;
@@ -34,6 +34,11 @@ const SelectionAssignmentMenu = ({
onCreate, onCreate,
disabled = false, disabled = false,
className, className,
triggerContent = null,
showStateIndicators = true,
showCounts = true,
onOpenMenu = null,
renderItemLabel = null,
}) => { }) => {
const anchorRef = useRef(null); const anchorRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
@@ -143,20 +148,32 @@ const SelectionAssignmentMenu = ({
&& query.trim().length > 0 && query.trim().length > 0
&& !existingLabels.has(query.trim().toLowerCase()); && !existingLabels.has(query.trim().toLowerCase());
const handleTriggerClick = useCallback(() => {
if (disabled) {
return;
}
if (!isOpen) {
onOpenMenu?.();
}
toggle();
}, [disabled, isOpen, onOpenMenu, toggle]);
return ( return (
<div className={className ? `selection-assignment ${className}` : 'selection-assignment'}> <div className={className ? `selection-assignment ${className}` : 'selection-assignment'}>
<button <button
type="button" type="button"
ref={anchorRef} ref={anchorRef}
className="quick-add__chip quick-add__trigger panel-floating-actions__trigger" className="quick-add__chip quick-add__trigger panel-floating-actions__trigger"
onClick={toggle} onClick={handleTriggerClick}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={isOpen} aria-expanded={isOpen}
disabled={disabled} disabled={disabled}
> >
{triggerContent ? triggerContent : (
<span className="quick-add__chip-label"> <span className="quick-add__chip-label">
{label} {label}
</span> </span>
)}
</button> </button>
{isOpen ? ( {isOpen ? (
<div <div
@@ -181,16 +198,21 @@ const SelectionAssignmentMenu = ({
filteredItems.map((item) => { filteredItems.map((item) => {
const isAll = item.state === 'all'; const isAll = item.state === 'all';
const isPartial = item.state === 'partial'; const isPartial = item.state === 'partial';
const icon = isAll ? ( const icon = showStateIndicators
<CheckIcon className="selection-assignment__icon" aria-hidden="true" /> ? isAll
) : isPartial ? ( ? <CheckIcon className="selection-assignment__icon" aria-hidden="true" />
<CircleDashedCheckIcon className="selection-assignment__icon" aria-hidden="true" /> : isPartial
) : ( ? <CircleDashedCheckIcon className="selection-assignment__icon" aria-hidden="true" />
<span className="selection-assignment__icon selection-assignment__icon--empty" aria-hidden="true" /> : <span className="selection-assignment__icon selection-assignment__icon--empty" aria-hidden="true" />
); : null;
const countLabel = item.total && (isPartial || isAll) const countLabel = showCounts && item.total && (isPartial || isAll)
? `${item.count ?? 0}/${item.total}` ? `${item.count ?? 0}/${item.total}`
: null; : null;
const labelContent = renderItemLabel ? renderItemLabel(item) : item.label;
const labelClassName = [
'selection-assignment__label',
(!showStateIndicators || !icon) ? 'selection-assignment__label--nowrap' : null,
].filter(Boolean).join(' ');
return ( return (
<button <button
key={item.id} key={item.id}
@@ -201,7 +223,9 @@ const SelectionAssignmentMenu = ({
role="menuitem" role="menuitem"
> >
{icon} {icon}
<span className="selection-assignment__label">{item.label}</span> <span className={labelClassName}>
{labelContent}
</span>
{countLabel ? ( {countLabel ? (
<span className="selection-assignment__count">{countLabel}</span> <span className="selection-assignment__count">{countLabel}</span>
) : null} ) : null}
@@ -1,11 +1,58 @@
import React, { useCallback, useMemo } from 'react'; import React, {
import { TrashIcon, AnalyzeIcon, IconX } from '../ui/icons'; useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
TrashIcon,
AnalyzeIcon,
IconX,
FolderOutlineIcon,
TagIcon,
CorrespondentIcon,
LoaderIcon,
} from '../ui/icons';
import SelectionAssignmentMenu from './SelectionAssignmentMenu'; import SelectionAssignmentMenu from './SelectionAssignmentMenu';
import SelectionSummary from './SelectionSummary'; import SelectionSummary from './SelectionSummary';
import { api, useAppState } from '../app/appState';
const normalizeDocumentList = (selectedDocumentIds) => const normalizeDocumentList = (selectedDocumentIds) =>
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : []; Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
const ROOT_FOLDER_LABEL = 'Documents';
const buildFolderTreeOptions = (tree) => {
const entries = [];
const traverse = (nodes, parentSegments) => {
if (!Array.isArray(nodes) || nodes.length === 0) {
return;
}
nodes.forEach((node) => {
if (!node || !node.id) {
return;
}
const name = typeof node.name === 'string' && node.name.trim().length
? node.name.trim()
: 'Folder';
const nextSegments = parentSegments.concat([name]);
const label = nextSegments.join('/');
entries.push({ id: node.id, label });
if (Array.isArray(node.children) && node.children.length) {
traverse(node.children, nextSegments);
}
});
};
traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]);
entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
return [{ id: 'root', label: ROOT_FOLDER_LABEL }, ...entries];
};
const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => { const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
if (!total) { if (!total) {
return []; return [];
@@ -118,6 +165,7 @@ const SelectionFloatingActions = ({
tags, tags,
tagLookupById, tagLookupById,
correspondents, correspondents,
folderOptions = [],
onBulkTagAdd, onBulkTagAdd,
onBulkTagRemove, onBulkTagRemove,
onBulkCorrespondentAdd, onBulkCorrespondentAdd,
@@ -125,7 +173,67 @@ const SelectionFloatingActions = ({
onBulkReanalyze, onBulkReanalyze,
onDeleteSelection, onDeleteSelection,
onClearSelection = null, onClearSelection = null,
onMoveDocumentsToFolder,
}) => { }) => {
const { token, tenant } = useAppState();
const tenantId = tenant?.id ?? null;
const [remoteFolderOptions, setRemoteFolderOptions] = useState(null);
const [loadingFolders, setLoadingFolders] = useState(false);
const folderTreeFetchRef = useRef(null);
useEffect(() => {
setRemoteFolderOptions(null);
folderTreeFetchRef.current = null;
setLoadingFolders(false);
}, [tenantId, token]);
const requestFolderTree = useCallback(async () => {
if (!token) {
setRemoteFolderOptions([]);
return [];
}
if (Array.isArray(remoteFolderOptions)) {
return remoteFolderOptions;
}
if (folderTreeFetchRef.current) {
return folderTreeFetchRef.current;
}
const fetchPromise = (async () => {
setLoadingFolders(true);
try {
const { data } = await api.get('/folders/tree');
const options = buildFolderTreeOptions(data);
setRemoteFolderOptions(options);
return options;
} catch (error) {
console.warn('[selection] Failed to load folder tree', error);
setRemoteFolderOptions([]);
return [];
} finally {
setLoadingFolders(false);
folderTreeFetchRef.current = null;
}
})();
folderTreeFetchRef.current = fetchPromise;
return fetchPromise;
}, [remoteFolderOptions, token]);
const handleMoveMenuOpen = useCallback(() => {
requestFolderTree();
}, [requestFolderTree]);
const effectiveFolderOptions = useMemo(() => {
if (remoteFolderOptions !== null) {
return remoteFolderOptions;
}
return Array.isArray(folderOptions) ? folderOptions : [];
}, [remoteFolderOptions, folderOptions]);
const documentIdList = useMemo( const documentIdList = useMemo(
() => normalizeDocumentList(selectedDocumentIds), () => normalizeDocumentList(selectedDocumentIds),
[selectedDocumentIds], [selectedDocumentIds],
@@ -153,6 +261,36 @@ const SelectionFloatingActions = ({
const selectedDocCount = selectedDocuments.length; const selectedDocCount = selectedDocuments.length;
const moveAssignments = useMemo(() => {
if (!Array.isArray(effectiveFolderOptions)) {
return [];
}
return effectiveFolderOptions
.map((option) => {
const id = option?.id ?? option?.value ?? option;
if (!id) {
return null;
}
const label = option?.label || option?.name || String(id);
const segments = label.split('/');
const depth = Math.max(segments.length - 1, 0);
return {
id,
label,
state: 'none',
count: null,
total: null,
payload: {
id,
label,
segments,
depth,
},
};
})
.filter(Boolean);
}, [effectiveFolderOptions]);
const tagAssignments = useMemo( const tagAssignments = useMemo(
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount), () => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount),
[selectedDocuments, tagLookupById, tags, selectedDocCount], [selectedDocuments, tagLookupById, tags, selectedDocCount],
@@ -163,6 +301,33 @@ const SelectionFloatingActions = ({
[selectedDocuments, correspondents, selectedDocCount], [selectedDocuments, correspondents, selectedDocCount],
); );
const renderFolderLabel = useCallback((item) => {
const segments = item?.payload?.segments || (item?.label ? item.label.split('/') : []);
const depth = item?.payload?.depth ?? Math.max(segments.length - 1, 0);
const clampedDepth = Math.min(depth, 6);
const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0;
const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder';
const parentPath = segments.length > 1 ? segments.slice(0, -1).join(' / ') : '';
return (
<>
{indentWidth ? (
<span
className="selection-assignment__indent"
style={{ width: `${indentWidth}rem` }}
aria-hidden="true"
/>
) : null}
<span className="selection-assignment__folder-label">
<span className="selection-assignment__folder-name">{name}</span>
{parentPath ? (
<span className="selection-assignment__folder-path">{parentPath}</span>
) : null}
</span>
</>
);
}, []);
const handleToggleTagAssignment = useCallback( const handleToggleTagAssignment = useCallback(
async (item) => { async (item) => {
if (!selectedDocCount || !item) { if (!selectedDocCount || !item) {
@@ -217,6 +382,20 @@ const SelectionFloatingActions = ({
[selectedDocCount, onBulkCorrespondentAdd, documentIdList], [selectedDocCount, onBulkCorrespondentAdd, documentIdList],
); );
const handleMoveSelectionToFolder = useCallback(
async (option) => {
if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') {
return;
}
const value = option?.id ?? option?.value ?? option;
if (!value) {
return;
}
await onMoveDocumentsToFolder(documentIdList, value);
},
[documentIdList, onMoveDocumentsToFolder],
);
const summaryNode = totalCount > 0 ? ( const summaryNode = totalCount > 0 ? (
<SelectionSummary <SelectionSummary
documentCount={documentCount} documentCount={documentCount}
@@ -231,8 +410,38 @@ const SelectionFloatingActions = ({
<span className="panel-floating__label">{summaryNode}</span> <span className="panel-floating__label">{summaryNode}</span>
) : null} ) : null}
<div className="panel-floating-actions"> <div className="panel-floating-actions">
{typeof onMoveDocumentsToFolder === 'function' ? (
<SelectionAssignmentMenu
label="Move"
triggerContent={(
<span className="quick-add__chip-label">
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
{' '}
Move
{loadingFolders ? (
<LoaderIcon className="icon-inline icon--spin selection-assignment__spinner" aria-hidden="true" />
) : null}
</span>
)}
items={moveAssignments}
placeholder="Search folders…"
emptyMessage={loadingFolders ? 'Loading folders…' : 'No folders'}
onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)}
disabled={!documentCount || (loadingFolders && !moveAssignments.length)}
createLabel={null}
showStateIndicators={false}
showCounts={false}
onOpenMenu={handleMoveMenuOpen}
renderItemLabel={renderFolderLabel}
/>
) : null}
<SelectionAssignmentMenu <SelectionAssignmentMenu
label="Tags" label="Tags"
triggerContent={(
<span className="quick-add__chip-label">
<TagIcon className="icon-inline" aria-hidden="true" /> Tags
</span>
)}
items={tagAssignments} items={tagAssignments}
placeholder="Search tags…" placeholder="Search tags…"
emptyMessage="No tags" emptyMessage="No tags"
@@ -243,6 +452,11 @@ const SelectionFloatingActions = ({
/> />
<SelectionAssignmentMenu <SelectionAssignmentMenu
label="Correspondents" label="Correspondents"
triggerContent={(
<span className="quick-add__chip-label">
<CorrespondentIcon className="icon-inline" aria-hidden="true" /> Correspondents
</span>
)}
items={correspondentAssignments} items={correspondentAssignments}
placeholder="Search correspondents…" placeholder="Search correspondents…"
emptyMessage="No correspondents" emptyMessage="No correspondents"
+769 -22
View File
@@ -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 PanelHeader from '../ui/PanelHeader';
import { CheckIcon, ChevronDownIcon, IconX } from '../ui/icons';
const SECTIONS = [ const SECTIONS = [
{ {
id: 'passkeys', id: 'passkeys',
label: 'Passkeys', label: 'Passkeys',
}, },
{
id: 'capabilitySets',
label: 'Capability sets',
},
{ {
id: 'apiTokens', id: 'apiTokens',
label: 'API tokens', 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 = ({ const SettingsModal = ({
open, open,
onClose, onClose,
@@ -22,10 +163,21 @@ const SettingsModal = ({
regeneratingId = null, regeneratingId = null,
capabilitySets = [], capabilitySets = [],
capabilitySetsLoading = false, capabilitySetsLoading = false,
creatingCapabilitySet = false,
savingCapabilitySetId = null,
deletingCapabilitySetId = null,
supportsCapabilitySetLabels = false,
capabilities = [],
capabilitiesLoading = false,
onRefresh, onRefresh,
onCreate, onRefreshCapabilitySets,
onDelete, onRefreshCapabilities,
onRegenerate, onCreate: onCreateToken,
onDelete: onDeleteToken,
onRegenerate: onRegenerateToken,
onCreateCapabilitySet,
onUpdateCapabilitySet,
onDeleteCapabilitySet,
createdToken = null, createdToken = null,
onDismissCreatedToken, onDismissCreatedToken,
passkeys = [], passkeys = [],
@@ -44,6 +196,15 @@ const SettingsModal = ({
const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState(''); const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState('');
const [formError, setFormError] = useState(null); const [formError, setFormError] = useState(null);
const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); 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(() => { const handleBackdropClick = useCallback(() => {
onClose?.(); onClose?.();
@@ -61,6 +222,15 @@ const SettingsModal = ({
setNewTokenCapabilitySetId(''); setNewTokenCapabilitySetId('');
setFormError(null); setFormError(null);
setNewPasskeyNickname(''); setNewPasskeyNickname('');
setNewCapabilitySetSlug('');
setNewCapabilitySetLabel('');
setNewCapabilitySetCapabilities([]);
setCapabilitySetFormError(null);
setEditingCapabilitySetId(null);
setEditCapabilitySetSlug('');
setEditCapabilitySetLabel('');
setEditCapabilitySetCapabilities([]);
setCapabilitySetEditError(null);
} }
}, [open, defaultSection]); }, [open, defaultSection]);
@@ -134,6 +304,65 @@ const SettingsModal = ({
[capabilitySetOptions], [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(() => { useEffect(() => {
if (!capabilitySetOptions.length) { if (!capabilitySetOptions.length) {
setNewTokenCapabilitySetId(''); setNewTokenCapabilitySetId('');
@@ -150,6 +379,182 @@ const SettingsModal = ({
setNewTokenCapabilitySetId(event.target.value); 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( const handleCreateToken = useCallback(
async (event) => { async (event) => {
event.preventDefault(); event.preventDefault();
@@ -181,7 +586,7 @@ const SettingsModal = ({
return; return;
} }
const result = await onCreate?.({ const result = await onCreateToken?.({
label: normalizedLabel, label: normalizedLabel,
expires_at: normalizedExpires, expires_at: normalizedExpires,
capability_set_id: selectedCapabilitySetId, capability_set_id: selectedCapabilitySetId,
@@ -199,7 +604,7 @@ const SettingsModal = ({
newTokenCapabilitySetId, newTokenCapabilitySetId,
newTokenExpires, newTokenExpires,
newTokenLabel, newTokenLabel,
onCreate, onCreateToken,
], ],
); );
@@ -208,11 +613,342 @@ const SettingsModal = ({
if (!token?.id) { if (!token?.id) {
return; 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 renderApiTokensSection = useMemo(() => {
const hasTokens = Array.isArray(tokens) && tokens.length > 0; const hasTokens = Array.isArray(tokens) && tokens.length > 0;
@@ -271,7 +1007,7 @@ const SettingsModal = ({
onChange={(event) => setNewTokenExpires(event.target.value)} onChange={(event) => setNewTokenExpires(event.target.value)}
/> />
</div> </div>
<div className="settings-form__field"> <div className="settings-form__field settings-form__field--full">
<label htmlFor="api-token-capability-set">Capability set</label> <label htmlFor="api-token-capability-set">Capability set</label>
<select <select
id="api-token-capability-set" id="api-token-capability-set"
@@ -298,11 +1034,14 @@ const SettingsModal = ({
{!capabilitySetsLoading && !capabilitySetOptions.length ? ( {!capabilitySetsLoading && !capabilitySetOptions.length ? (
<small>No capability sets available.</small> <small>No capability sets available.</small>
) : null} ) : null}
{!capabilitySetsLoading {!capabilitySetsLoading && selectedTokenCapabilitySetCapabilities.length ? (
&& capabilitySetMap[newTokenCapabilitySetId]?.capabilities?.length ? ( <div className="settings-capability-picker__chips settings-capability-picker__chips--inline">
<small> {selectedTokenCapabilitySetCapabilities.map((value) => (
Includes: {capabilitySetMap[newTokenCapabilitySetId].capabilities.join(', ')} <span key={value} className="badge tag-chip">
</small> <span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
</span>
))}
</div>
) : null} ) : null}
</div> </div>
<div className="settings-form__actions"> <div className="settings-form__actions">
@@ -346,9 +1085,11 @@ const SettingsModal = ({
const selectedSet = token?.capability_set_id const selectedSet = token?.capability_set_id
? capabilitySetMap[token.capability_set_id] ? capabilitySetMap[token.capability_set_id]
: null; : null;
const capabilityList = Array.isArray(token?.capabilities) && token.capabilities.length const capabilityList = sortCapabilityValues(
Array.isArray(token?.capabilities) && token.capabilities.length
? token.capabilities ? token.capabilities
: selectedSet?.capabilities || []; : selectedSet?.capabilities || [],
);
const capabilitySetLabel = selectedSet?.label const capabilitySetLabel = selectedSet?.label
|| selectedSet?.slug || selectedSet?.slug
|| token.capability_set_id || token.capability_set_id
@@ -367,9 +1108,9 @@ const SettingsModal = ({
<small>Loading capability sets</small> <small>Loading capability sets</small>
) : null} ) : null}
{!capabilitySetsLoading && capabilityList.length ? ( {!capabilitySetsLoading && capabilityList.length ? (
<div className="settings-capabilities"> <small className="settings-capabilities-summary">
<small>Includes: {capabilityList.join(', ')}</small> {capabilityList.length} capabilities
</div> </small>
) : null} ) : null}
</td> </td>
<td className="settings-table__actions"> <td className="settings-table__actions">
@@ -390,7 +1131,7 @@ const SettingsModal = ({
<button <button
type="button" type="button"
className="danger" className="danger"
onClick={() => onDelete?.(token.id)} onClick={() => onDeleteToken?.(token.id)}
disabled={ disabled={
deletingId === token.id || regeneratingId === token.id deletingId === token.id || regeneratingId === token.id
} }
@@ -422,12 +1163,15 @@ const SettingsModal = ({
capabilitySetOptions, capabilitySetOptions,
capabilitySetMap, capabilitySetMap,
capabilitySetsLoading, capabilitySetsLoading,
sortCapabilityValues,
selectedTokenCapabilitySetCapabilities,
formatCapabilityLabel,
formatDateTime, formatDateTime,
handleCopyToken, handleCopyToken,
handleCreateToken, handleCreateToken,
handleRegenerateToken, handleRegenerateToken,
handleRefresh, handleRefresh,
onDelete, onDeleteToken,
handleDismissSecret, handleDismissSecret,
handleNewCapabilitySetChange, handleNewCapabilitySetChange,
]); ]);
@@ -594,8 +1338,11 @@ const SettingsModal = ({
</nav> </nav>
<div className="settings-modal__content"> <div className="settings-modal__content">
{activeSection === 'passkeys' ? renderPasskeysSection : null} {activeSection === 'passkeys' ? renderPasskeysSection : null}
{activeSection === 'capabilitySets' ? renderCapabilitySetsSection : null}
{activeSection === 'apiTokens' ? renderApiTokensSection : null} {activeSection === 'apiTokens' ? renderApiTokensSection : null}
{activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? ( {activeSection !== 'passkeys'
&& activeSection !== 'capabilitySets'
&& activeSection !== 'apiTokens' ? (
<p>Select a settings section.</p> <p>Select a settings section.</p>
) : null} ) : null}
</div> </div>
+1 -31
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useState } from 'react';
const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
const [tokens, setTokens] = useState([]); const [tokens, setTokens] = useState([]);
@@ -7,25 +7,6 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
const [deletingId, setDeletingId] = useState(null); const [deletingId, setDeletingId] = useState(null);
const [regeneratingId, setRegeneratingId] = useState(null); const [regeneratingId, setRegeneratingId] = useState(null);
const [createdSecret, setCreatedSecret] = useState(null); const [createdSecret, setCreatedSecret] = useState(null);
const [capabilitySets, setCapabilitySets] = useState([]);
const [capabilitySetsLoading, setCapabilitySetsLoading] = useState(false);
const refreshCapabilitySets = useCallback(async () => {
if (!token) {
setCapabilitySets([]);
return;
}
setCapabilitySetsLoading(true);
try {
const { data } = await api.get('/capability-sets');
setCapabilitySets(Array.isArray(data) ? data : []);
} catch (error) {
notifyApiError?.(error, 'Failed to load capability sets.');
} finally {
setCapabilitySetsLoading(false);
}
}, [api, notifyApiError, token]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!token) { if (!token) {
return; return;
@@ -150,14 +131,6 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
[api, notifyApiError, refresh, setStatusMessage], [api, notifyApiError, refresh, setStatusMessage],
); );
useEffect(() => {
if (token) {
refreshCapabilitySets();
} else {
setCapabilitySets([]);
}
}, [token, refreshCapabilitySets]);
const dismissSecret = useCallback(() => { const dismissSecret = useCallback(() => {
setCreatedSecret(null); setCreatedSecret(null);
}, []); }, []);
@@ -174,9 +147,6 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
revoke, revoke,
regenerate, regenerate,
dismissSecret, dismissSecret,
capabilitySets,
capabilitySetsLoading,
refreshCapabilitySets,
}; };
}; };
+43
View File
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from 'react';
const useCapabilities = ({ api, notifyApiError, token }) => {
const [capabilities, setCapabilities] = useState([]);
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
const refreshCapabilities = useCallback(async () => {
if (!token) {
setCapabilities([]);
return;
}
setCapabilitiesLoading(true);
try {
const { data } = await api.get('/capabilities');
if (Array.isArray(data)) {
setCapabilities(data);
} else {
setCapabilities([]);
}
} catch (error) {
notifyApiError?.(error, 'Failed to load capabilities.');
setCapabilities([]);
} finally {
setCapabilitiesLoading(false);
}
}, [api, notifyApiError, token]);
useEffect(() => {
if (token) {
refreshCapabilities();
} else {
setCapabilities([]);
}
}, [refreshCapabilities, token]);
return {
capabilities,
capabilitiesLoading,
refreshCapabilities,
};
};
export default useCapabilities;
+203
View File
@@ -0,0 +1,203 @@
import { useCallback, useEffect, useState } from 'react';
const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) => {
const [capabilitySets, setCapabilitySets] = useState([]);
const [capabilitySetsLoading, setCapabilitySetsLoading] = useState(false);
const [creatingCapabilitySet, setCreatingCapabilitySet] = useState(false);
const [savingCapabilitySetId, setSavingCapabilitySetId] = useState(null);
const [deletingCapabilitySetId, setDeletingCapabilitySetId] = useState(null);
const [supportsCapabilitySetLabels, setSupportsCapabilitySetLabels] = useState(false);
const applyCapabilitySets = useCallback((updater) => {
setCapabilitySets((previous) => {
const base = Array.isArray(previous) ? [...previous] : [];
const next = typeof updater === 'function'
? updater(base)
: (Array.isArray(updater) ? [...updater] : base);
const supportsLabels = next.some((item) => Object.prototype.hasOwnProperty.call(item || {}, 'label'));
setSupportsCapabilitySetLabels(supportsLabels);
return next;
});
}, []);
const refreshCapabilitySets = useCallback(async () => {
if (!token) {
applyCapabilitySets([]);
return;
}
setCapabilitySetsLoading(true);
try {
const { data } = await api.get('/capability-sets');
applyCapabilitySets(Array.isArray(data) ? data : []);
} catch (error) {
notifyApiError?.(error, 'Failed to load capability sets.');
} finally {
setCapabilitySetsLoading(false);
}
}, [api, applyCapabilitySets, notifyApiError, token]);
useEffect(() => {
if (token) {
refreshCapabilitySets();
} else {
applyCapabilitySets([]);
}
}, [applyCapabilitySets, refreshCapabilitySets, token]);
const createCapabilitySet = useCallback(
async ({ slug, label, capabilities } = {}) => {
if (creatingCapabilitySet) {
return false;
}
if (!Array.isArray(capabilities) || capabilities.length === 0) {
setStatusMessage?.('Select at least one capability.', 'error');
return false;
}
setCreatingCapabilitySet(true);
try {
const payload = {
capabilities,
};
const trimmedSlug = slug?.trim();
if (trimmedSlug) {
payload.slug = trimmedSlug;
}
const trimmedLabel = label?.trim();
if (trimmedLabel && supportsCapabilitySetLabels) {
payload.label = trimmedLabel;
}
const { data } = await api.post('/capability-sets', payload);
if (data) {
applyCapabilitySets((previous) => {
const next = previous.filter((entry) => entry?.id !== data.id);
next.push(data);
next.sort((a, b) => a.slug.localeCompare(b.slug));
return next;
});
} else {
await refreshCapabilitySets();
}
setStatusMessage?.('Capability set created.', 'success');
return data;
} catch (error) {
notifyApiError?.(error, 'Failed to create capability set.');
return false;
} finally {
setCreatingCapabilitySet(false);
}
},
[
api,
applyCapabilitySets,
creatingCapabilitySet,
notifyApiError,
refreshCapabilitySets,
setStatusMessage,
supportsCapabilitySetLabels,
],
);
const updateCapabilitySet = useCallback(
async (capabilitySetId, { slug, label, capabilities } = {}) => {
if (!capabilitySetId) {
return false;
}
setSavingCapabilitySetId(capabilitySetId);
try {
const payload = {};
if (slug !== undefined) {
const trimmed = slug?.trim();
if (trimmed) {
payload.slug = trimmed;
} else if (slug === '') {
payload.slug = '';
}
}
if (label !== undefined && supportsCapabilitySetLabels) {
const trimmed = label?.trim();
if (trimmed) {
payload.label = trimmed;
} else if (label === '') {
payload.label = '';
}
}
if (Array.isArray(capabilities)) {
payload.capabilities = capabilities;
}
const { data } = await api.patch(`/capability-sets/${capabilitySetId}`, payload);
if (data) {
applyCapabilitySets((previous) => {
let found = false;
const next = previous.map((entry) => {
if (entry?.id === data.id) {
found = true;
return data;
}
return entry;
});
if (!found) {
next.push(data);
}
next.sort((a, b) => a.slug.localeCompare(b.slug));
return next;
});
} else {
await refreshCapabilitySets();
}
setStatusMessage?.('Capability set updated.', 'success');
return true;
} catch (error) {
notifyApiError?.(error, 'Failed to update capability set.');
return false;
} finally {
setSavingCapabilitySetId(null);
}
},
[
api,
applyCapabilitySets,
notifyApiError,
refreshCapabilitySets,
setStatusMessage,
supportsCapabilitySetLabels,
],
);
const deleteCapabilitySet = useCallback(
async (capabilitySetId) => {
if (!capabilitySetId) {
return false;
}
setDeletingCapabilitySetId(capabilitySetId);
try {
await api.delete(`/capability-sets/${capabilitySetId}`);
applyCapabilitySets((previous) => previous.filter((entry) => entry?.id !== capabilitySetId));
setStatusMessage?.('Capability set deleted.', 'success');
return true;
} catch (error) {
notifyApiError?.(error, 'Failed to delete capability set.');
return false;
} finally {
setDeletingCapabilitySetId(null);
}
},
[api, applyCapabilitySets, notifyApiError, setStatusMessage],
);
return {
capabilitySets,
capabilitySetsLoading,
creatingCapabilitySet,
savingCapabilitySetId,
deletingCapabilitySetId,
supportsCapabilitySetLabels,
refreshCapabilitySets,
createCapabilitySet,
updateCapabilitySet,
deleteCapabilitySet,
};
};
export default useCapabilitySets;
+177
View File
@@ -560,6 +560,16 @@ button.danger:hover:not([disabled]) {
justify-content: center; justify-content: center;
} }
@keyframes icon-spin {
to {
transform: rotate(360deg);
}
}
.icon--spin {
animation: icon-spin 0.9s linear infinite;
}
.icon--flip-y { .icon--flip-y {
transform: scaleX(-1); transform: scaleX(-1);
} }
@@ -2887,6 +2897,7 @@ button.danger:hover:not([disabled]) {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 400; font-weight: 400;
padding: 0.4rem 0; padding: 0.4rem 0;
max-width: min(22rem, 90vw);
} }
.selection-assignment__header { .selection-assignment__header {
@@ -2923,6 +2934,61 @@ button.danger:hover:not([disabled]) {
font-weight: 400; font-weight: 400;
} }
.selection-assignment__label {
flex: 1 1 auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.selection-assignment__spinner {
margin-left: 0.4rem;
}
.selection-assignment__label--nowrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selection-assignment__indent {
display: inline-block;
flex: 0 0 auto;
}
.selection-assignment__folder-label {
display: inline-flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
max-width: 16rem;
}
.selection-assignment__folder-name {
font-weight: 500;
color: var(--fg);
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selection-assignment__folder-path {
font-size: 0.8rem;
color: var(--muted);
white-space: normal;
word-break: break-word;
}
.selection-assignment__slash {
color: var(--muted);
margin: 0 0.25rem;
}
.selection-assignment__segment {
display: inline-block;
}
.selection-assignment__item--all .selection-assignment__icon { .selection-assignment__item--all .selection-assignment__icon {
color: var(--success); color: var(--success);
} }
@@ -3611,6 +3677,11 @@ form.inline {
min-width: 14rem; min-width: 14rem;
} }
.settings-form__field--full {
flex: 1 1 100%;
min-width: 100%;
}
fieldset.settings-form__field { fieldset.settings-form__field {
border: 1px solid var(--border-muted, var(--border)); border: 1px solid var(--border-muted, var(--border));
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -3640,6 +3711,112 @@ fieldset.settings-form__field legend {
align-items: flex-start; align-items: flex-start;
} }
.settings-capability-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.capability-dropdown {
position: relative;
width: 100%;
}
.capability-dropdown__trigger {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.45rem;
background: var(--surface-soft);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.capability-dropdown__trigger:hover:not(:disabled),
.capability-dropdown__trigger:focus-visible {
border-color: var(--accent);
background: var(--surface);
outline: none;
}
.capability-dropdown__trigger:disabled {
cursor: not-allowed;
color: var(--muted);
background: var(--surface-muted, var(--surface-soft));
}
.capability-dropdown__chevron {
flex-shrink: 0;
opacity: 0.8;
}
.capability-dropdown__menu {
margin-top: 0.35rem;
max-height: 18rem;
overflow-y: auto;
padding: 0.25rem 0;
width: 100%;
}
.capability-dropdown__option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
}
.capability-dropdown__option-icon {
width: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
.capability-dropdown__option:not(.is-selected) .capability-dropdown__option-icon {
color: transparent;
}
.capability-dropdown__option-label {
flex: 1;
text-align: left;
}
.capability-dropdown__empty {
padding: 0.6rem 0.8rem;
color: var(--muted);
}
.settings-capability-picker__chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
}
.settings-capability-picker__chips--inline {
margin-top: 0.35rem;
}
.settings-capabilities-summary {
display: inline-block;
margin-top: 0.4rem;
color: var(--muted);
font-size: 0.9rem;
}
.settings-capability-picker__placeholder {
color: var(--muted);
font-size: 0.9rem;
}
.settings-choice { .settings-choice {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+12 -1
View File
@@ -14,6 +14,7 @@ import {
IconWindowMaximize, IconWindowMaximize,
IconTextScan2, IconTextScan2,
IconFolderPlus, IconFolderPlus,
IconFolder,
IconRefresh, IconRefresh,
IconRestore, IconRestore,
IconMinusVertical, IconMinusVertical,
@@ -32,7 +33,7 @@ import {
IconInfoCircle, IconInfoCircle,
IconCircleDashedCheck, IconCircleDashedCheck,
IconFile, IconFile,
IconFolder, IconLoader,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg'; import FolderSvg from '../assets/folder.svg';
@@ -369,6 +370,15 @@ export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
/> />
); );
export const LoaderIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) => (
<IconLoader
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconWindowMaximize <IconWindowMaximize
className={composeClassName('icon', className)} className={composeClassName('icon', className)}
@@ -410,6 +420,7 @@ export default {
SidebarExpandIcon, SidebarExpandIcon,
DetailPanelCollapseIcon, DetailPanelCollapseIcon,
AnalyzeIcon, AnalyzeIcon,
LoaderIcon,
WindowMaximizeIcon, WindowMaximizeIcon,
FolderPlusIcon, FolderPlusIcon,
RefreshIcon, RefreshIcon,