folders-move
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user