From a16f1501d3809b3c60f9c6f04e9844eabdc93f48 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 6 Nov 2025 00:58:17 +0100 Subject: [PATCH] folders-move --- frontend/src/DesktopWorkspace.jsx | 4 + frontend/src/app/AppLayout.jsx | 8 + frontend/src/app/SettingsRoute.jsx | 67 +- frontend/src/documents/DocumentsPanel.jsx | 4 + .../src/documents/SelectionAssignmentMenu.jsx | 50 +- .../documents/SelectionFloatingActions.jsx | 218 ++++- frontend/src/settings/SettingsModal.jsx | 795 +++++++++++++++++- frontend/src/settings/useApiTokens.js | 32 +- frontend/src/settings/useCapabilities.js | 43 + frontend/src/settings/useCapabilitySets.js | 203 +++++ frontend/src/styles.css | 177 ++++ frontend/src/ui/icons.js | 13 +- 12 files changed, 1525 insertions(+), 89 deletions(-) create mode 100644 frontend/src/settings/useCapabilities.js create mode 100644 frontend/src/settings/useCapabilitySets.js diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index c445c37..da8775c 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -2768,6 +2768,8 @@ export const createDesktopSurface = ({ onBulkCorrespondentAdd, onBulkCorrespondentRemove, onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, } = workspaceProps; const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; @@ -2803,6 +2805,8 @@ export const createDesktopSurface = ({ onBulkReanalyze={onBulkReanalyze} onDeleteSelection={onDeleteSelection} onClearSelection={onClearSelection} + folderOptions={folderOptions} + onMoveDocumentsToFolder={onMoveDocumentsToFolder} /> ) : null; diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index b7407f5..43b3556 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -4575,6 +4575,8 @@ const AppLayout = () => { onBulkCorrespondentAdd: handleBulkCorrespondentAdd, onBulkCorrespondentRemove: handleBulkCorrespondentRemove, onBulkReanalyze: handleBulkSelectionReanalyze, + folderOptions, + onMoveDocumentsToFolder: moveDocumentsToFolder, }), [ activeCorrespondentFilters, @@ -4623,6 +4625,8 @@ const AppLayout = () => { handleBulkCorrespondentAdd, handleBulkCorrespondentRemove, handleBulkSelectionReanalyze, + folderOptions, + moveDocumentsToFolder, ], ); @@ -4882,6 +4886,8 @@ const AppLayout = () => { onBulkCorrespondentAdd: handleBulkCorrespondentAdd, onBulkCorrespondentRemove: handleBulkCorrespondentRemove, onBulkReanalyze: handleBulkSelectionReanalyze, + folderOptions, + onMoveDocumentsToFolder: moveDocumentsToFolder, }), [ documents, @@ -4922,6 +4928,8 @@ const AppLayout = () => { handleBulkCorrespondentAdd, handleBulkCorrespondentRemove, handleBulkSelectionReanalyze, + folderOptions, + moveDocumentsToFolder, ], ); diff --git a/frontend/src/app/SettingsRoute.jsx b/frontend/src/app/SettingsRoute.jsx index c6f6665..165bfa0 100644 --- a/frontend/src/app/SettingsRoute.jsx +++ b/frontend/src/app/SettingsRoute.jsx @@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom'; import SettingsModal from '../settings/SettingsModal'; import { useAppShell } from '../appShellContext'; import useApiTokens from '../settings/useApiTokens'; +import useCapabilitySets from '../settings/useCapabilitySets'; +import useCapabilities from '../settings/useCapabilities'; import { api } from './appState'; const SettingsRoute = () => { @@ -23,31 +25,49 @@ const SettingsRoute = () => { const { tokens, - loading, - creating, + loading: tokensLoading, + creating: creatingToken, deletingId, regeneratingId, createdSecret, - refresh, - refreshCapabilitySets, - create, - revoke, - regenerate, + refresh: refreshTokens, + create: createToken, + revoke: revokeToken, + regenerate: regenerateToken, dismissSecret, - capabilitySets, - capabilitySetsLoading, } = 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(() => { - refresh(); + refreshTokens(); refreshCapabilitySets(); + refreshCapabilities(); refreshPasskeys(); - }, [refresh, refreshCapabilitySets, refreshPasskeys]); + }, [refreshTokens, refreshCapabilitySets, refreshCapabilities, refreshPasskeys]); const handleRefresh = useCallback(() => { - refresh(); + refreshTokens(); refreshCapabilitySets(); - }, [refresh, refreshCapabilitySets]); + refreshCapabilities(); + }, [refreshTokens, refreshCapabilitySets, refreshCapabilities]); const handleClose = useCallback(() => { dismissSecret(); @@ -74,18 +94,29 @@ const SettingsRoute = () => { open onClose={handleClose} tokens={tokens} - loading={loading} - creating={creating} + loading={tokensLoading} + creating={creatingToken} deletingId={deletingId} regeneratingId={regeneratingId} onRefresh={handleRefresh} - onCreate={create} - onDelete={revoke} - onRegenerate={regenerate} + onCreate={createToken} + onDelete={revokeToken} + onRegenerate={regenerateToken} createdToken={createdSecret} onDismissCreatedToken={dismissSecret} capabilitySets={capabilitySets} 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} passkeysSupported={passkeysSupported} passkeysLoading={passkeysLoading} diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index deec470..dc3965c 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -844,6 +844,8 @@ export const createDocumentsSurface = ({ onBulkCorrespondentAdd, onBulkCorrespondentRemove, onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, } = tableProps; const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; @@ -879,6 +881,8 @@ export const createDocumentsSurface = ({ onBulkReanalyze={onBulkReanalyze} onDeleteSelection={onDeleteSelection} onClearSelection={onClearSelection} + folderOptions={folderOptions} + onMoveDocumentsToFolder={onMoveDocumentsToFolder} /> ) : null; diff --git a/frontend/src/documents/SelectionAssignmentMenu.jsx b/frontend/src/documents/SelectionAssignmentMenu.jsx index 8a77e24..7f2742e 100644 --- a/frontend/src/documents/SelectionAssignmentMenu.jsx +++ b/frontend/src/documents/SelectionAssignmentMenu.jsx @@ -34,6 +34,11 @@ const SelectionAssignmentMenu = ({ onCreate, disabled = false, className, + triggerContent = null, + showStateIndicators = true, + showCounts = true, + onOpenMenu = null, + renderItemLabel = null, }) => { const anchorRef = useRef(null); const inputRef = useRef(null); @@ -143,20 +148,32 @@ const SelectionAssignmentMenu = ({ && query.trim().length > 0 && !existingLabels.has(query.trim().toLowerCase()); + const handleTriggerClick = useCallback(() => { + if (disabled) { + return; + } + if (!isOpen) { + onOpenMenu?.(); + } + toggle(); + }, [disabled, isOpen, onOpenMenu, toggle]); + return (
{isOpen ? (
{ const isAll = item.state === 'all'; const isPartial = item.state === 'partial'; - const icon = isAll ? ( -
+ ); +}; + const SettingsModal = ({ open, onClose, @@ -22,10 +163,21 @@ const SettingsModal = ({ regeneratingId = null, capabilitySets = [], capabilitySetsLoading = false, + creatingCapabilitySet = false, + savingCapabilitySetId = null, + deletingCapabilitySetId = null, + supportsCapabilitySetLabels = false, + capabilities = [], + capabilitiesLoading = false, onRefresh, - onCreate, - onDelete, - onRegenerate, + onRefreshCapabilitySets, + onRefreshCapabilities, + onCreate: onCreateToken, + onDelete: onDeleteToken, + onRegenerate: onRegenerateToken, + onCreateCapabilitySet, + onUpdateCapabilitySet, + onDeleteCapabilitySet, createdToken = null, onDismissCreatedToken, passkeys = [], @@ -44,6 +196,15 @@ const SettingsModal = ({ const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState(''); const [formError, setFormError] = useState(null); const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); + const [newCapabilitySetSlug, setNewCapabilitySetSlug] = useState(''); + const [newCapabilitySetLabel, setNewCapabilitySetLabel] = useState(''); + const [newCapabilitySetCapabilities, setNewCapabilitySetCapabilities] = useState([]); + const [capabilitySetFormError, setCapabilitySetFormError] = useState(null); + const [editingCapabilitySetId, setEditingCapabilitySetId] = useState(null); + const [editCapabilitySetSlug, setEditCapabilitySetSlug] = useState(''); + const [editCapabilitySetLabel, setEditCapabilitySetLabel] = useState(''); + const [editCapabilitySetCapabilities, setEditCapabilitySetCapabilities] = useState([]); + const [capabilitySetEditError, setCapabilitySetEditError] = useState(null); const handleBackdropClick = useCallback(() => { onClose?.(); @@ -61,6 +222,15 @@ const SettingsModal = ({ setNewTokenCapabilitySetId(''); setFormError(null); setNewPasskeyNickname(''); + setNewCapabilitySetSlug(''); + setNewCapabilitySetLabel(''); + setNewCapabilitySetCapabilities([]); + setCapabilitySetFormError(null); + setEditingCapabilitySetId(null); + setEditCapabilitySetSlug(''); + setEditCapabilitySetLabel(''); + setEditCapabilitySetCapabilities([]); + setCapabilitySetEditError(null); } }, [open, defaultSection]); @@ -134,6 +304,65 @@ const SettingsModal = ({ [capabilitySetOptions], ); + const capabilitySelectionOptions = useMemo(() => ( + Array.isArray(capabilities) + ? capabilities.map((capability) => { + if (typeof capability !== 'string') { + return { value: capability, label: String(capability) }; + } + const [namespace, action] = capability.split(':'); + if (!namespace || !action) { + return { value: capability, label: capability }; + } + const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`; + const formattedAction = action.replace(/_/g, ' '); + return { + value: capability, + label: `${formattedNamespace}: ${formattedAction}`, + }; + }) + : [] + ), [capabilities]); + + const capabilityLabelMap = useMemo(() => { + const map = new Map(); + capabilitySelectionOptions.forEach(({ value, label }) => { + map.set(value, label || String(value)); + }); + return map; + }, [capabilitySelectionOptions]); + + const capabilityOrder = useMemo(() => { + const order = new Map(); + capabilitySelectionOptions.forEach((option, index) => { + order.set(option.value, index); + }); + return order; + }, [capabilitySelectionOptions]); + + const sortCapabilityValues = useCallback((values) => { + if (!Array.isArray(values)) { + return []; + } + return [...values].sort((a, b) => { + const indexA = capabilityOrder.has(a) ? capabilityOrder.get(a) : Number.MAX_SAFE_INTEGER; + const indexB = capabilityOrder.has(b) ? capabilityOrder.get(b) : Number.MAX_SAFE_INTEGER; + if (indexA === indexB) { + return String(a).localeCompare(String(b)); + } + return indexA - indexB; + }); + }, [capabilityOrder]); + + const formatCapabilityLabel = useCallback((value) => ( + capabilityLabelMap.get(value) || String(value) + ), [capabilityLabelMap]); + + const selectedTokenCapabilitySetCapabilities = useMemo( + () => capabilitySetMap[newTokenCapabilitySetId]?.capabilities || [], + [capabilitySetMap, newTokenCapabilitySetId], + ); + useEffect(() => { if (!capabilitySetOptions.length) { setNewTokenCapabilitySetId(''); @@ -150,6 +379,182 @@ const SettingsModal = ({ setNewTokenCapabilitySetId(event.target.value); }, []); + const handleCapabilitySetsRefresh = useCallback(() => { + if (onRefreshCapabilitySets) { + onRefreshCapabilitySets(); + } else { + onRefresh?.(); + } + onRefreshCapabilities?.(); + }, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]); + + const handleAddCapabilityToNewSet = useCallback((option) => { + const value = option?.value ?? option?.id ?? option; + if (!value) { + return; + } + setCapabilitySetFormError(null); + setNewCapabilitySetCapabilities((previous) => { + if (previous.includes(value)) { + return previous; + } + return sortCapabilityValues([...previous, value]); + }); + }, [sortCapabilityValues]); + + const handleRemoveCapabilityFromNewSet = useCallback((value) => { + setCapabilitySetFormError(null); + setNewCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); + }, []); + + const handleCreateCapabilitySetSubmit = useCallback( + async (event) => { + event.preventDefault(); + setCapabilitySetFormError(null); + + if (!Array.isArray(newCapabilitySetCapabilities) || newCapabilitySetCapabilities.length === 0) { + setCapabilitySetFormError('Select at least one capability.'); + return; + } + + if (!capabilitySelectionOptions.length) { + setCapabilitySetFormError('Capabilities are still loading.'); + return; + } + + const payload = { + slug: newCapabilitySetSlug, + label: newCapabilitySetLabel, + capabilities: sortCapabilityValues(newCapabilitySetCapabilities), + }; + + const result = await onCreateCapabilitySet?.(payload); + if (result === false) { + setCapabilitySetFormError('Failed to create capability set.'); + return; + } + + setNewCapabilitySetSlug(''); + setNewCapabilitySetLabel(''); + setNewCapabilitySetCapabilities([]); + setCapabilitySetFormError(null); + }, + [ + capabilitySelectionOptions, + newCapabilitySetCapabilities, + newCapabilitySetLabel, + newCapabilitySetSlug, + onCreateCapabilitySet, + sortCapabilityValues, + ], + ); + + const handleStartEditCapabilitySet = useCallback((capabilitySet) => { + if (!capabilitySet) { + return; + } + setCapabilitySetEditError(null); + setEditingCapabilitySetId(capabilitySet.id); + setEditCapabilitySetSlug(capabilitySet.slug || ''); + setEditCapabilitySetLabel(capabilitySet.label || ''); + setEditCapabilitySetCapabilities( + sortCapabilityValues(Array.isArray(capabilitySet.capabilities) ? capabilitySet.capabilities : []), + ); + }, [sortCapabilityValues]); + + const handleCancelEditCapabilitySet = useCallback(() => { + setEditingCapabilitySetId(null); + setEditCapabilitySetSlug(''); + setEditCapabilitySetLabel(''); + setEditCapabilitySetCapabilities([]); + setCapabilitySetEditError(null); + }, []); + + const handleAddCapabilityToEditSet = useCallback((option) => { + const value = option?.value ?? option?.id ?? option; + if (!value) { + return; + } + setCapabilitySetEditError(null); + setEditCapabilitySetCapabilities((previous) => { + if (previous.includes(value)) { + return previous; + } + return sortCapabilityValues([...previous, value]); + }); + }, [sortCapabilityValues]); + + const handleRemoveCapabilityFromEditSet = useCallback((value) => { + setCapabilitySetEditError(null); + setEditCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); + }, []); + + const handleUpdateCapabilitySetSubmit = useCallback( + async (event) => { + event.preventDefault(); + if (!editingCapabilitySetId) { + return; + } + if (!Array.isArray(editCapabilitySetCapabilities) || editCapabilitySetCapabilities.length === 0) { + setCapabilitySetEditError('Select at least one capability.'); + return; + } + + const payload = { + slug: editCapabilitySetSlug, + label: editCapabilitySetLabel, + capabilities: sortCapabilityValues(editCapabilitySetCapabilities), + }; + + const result = await onUpdateCapabilitySet?.(editingCapabilitySetId, payload); + if (result === false) { + setCapabilitySetEditError('Failed to update capability set.'); + return; + } + + handleCancelEditCapabilitySet(); + }, + [ + editCapabilitySetCapabilities, + editCapabilitySetLabel, + editCapabilitySetSlug, + editingCapabilitySetId, + handleCancelEditCapabilitySet, + onUpdateCapabilitySet, + sortCapabilityValues, + ], + ); + + const handleDeleteCapabilitySet = useCallback( + async (capabilitySet) => { + if (!capabilitySet?.id) { + return; + } + const displayName = capabilitySet.slug || capabilitySet.label || capabilitySet.id; + const confirmed = window.confirm( + `Delete capability set "${displayName}"?`, + ); + if (!confirmed) { + return; + } + const result = await onDeleteCapabilitySet?.(capabilitySet.id); + if (result === false) { + setCapabilitySetEditError('Failed to delete capability set.'); + } + }, + [onDeleteCapabilitySet], + ); + + useEffect(() => { + if (!editingCapabilitySetId) { + return; + } + const exists = capabilitySets.some((set) => set.id === editingCapabilitySetId); + if (!exists) { + handleCancelEditCapabilitySet(); + } + }, [capabilitySets, editingCapabilitySetId, handleCancelEditCapabilitySet]); + const handleCreateToken = useCallback( async (event) => { event.preventDefault(); @@ -181,7 +586,7 @@ const SettingsModal = ({ return; } - const result = await onCreate?.({ + const result = await onCreateToken?.({ label: normalizedLabel, expires_at: normalizedExpires, capability_set_id: selectedCapabilitySetId, @@ -199,7 +604,7 @@ const SettingsModal = ({ newTokenCapabilitySetId, newTokenExpires, newTokenLabel, - onCreate, + onCreateToken, ], ); @@ -208,11 +613,342 @@ const SettingsModal = ({ if (!token?.id) { return; } - await onRegenerate?.(token.id); + await onRegenerateToken?.(token.id); }, - [onRegenerate], + [onRegenerateToken], ); + const renderCapabilitySetsSection = useMemo(() => { + const hasCapabilitySets = Array.isArray(capabilitySets) && capabilitySets.length > 0; + const columnCount = supportsCapabilitySetLabels ? 6 : 5; + + return ( +
+
+ +
+ +

+ Capability sets bundle permissions that you can assign to API tokens and user memberships. +

+ +
+
+ + setNewCapabilitySetSlug(event.target.value)} + placeholder="e.g. api_readonly" + disabled={creatingCapabilitySet || capabilitySetsLoading} + /> + Leave blank to generate a slug automatically. +
+ {supportsCapabilitySetLabels ? ( +
+ + setNewCapabilitySetLabel(event.target.value)} + placeholder="Friendly name (optional)" + disabled={creatingCapabilitySet || capabilitySetsLoading} + /> +
+ ) : null} +
+ +
+ + {newCapabilitySetCapabilities.length ? ( +
+ {newCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + + ))} +
+ ) : ( + No capabilities selected. + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} + {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( + No capabilities available. + ) : null} +
+
+
+ +
+
+ {capabilitySetFormError ? ( +

{capabilitySetFormError}

+ ) : null} + {capabilitySetsLoading && !hasCapabilitySets ? ( +

Loading capability sets…

+ ) : null} + + {!capabilitySetsLoading && !hasCapabilitySets ? ( +

No capability sets yet.

+ ) : null} + + {hasCapabilitySets ? ( + + + + + {supportsCapabilitySetLabels ? : null} + + + + + + + + {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 ( + + + + {supportsCapabilitySetLabels ? ( + + ) : null} + + + + + + {isEditing ? ( + + + + ) : null} + + ); + })} + +
SlugLabelCapabilitiesSystemVersionActions
{set.slug || '—'}{set.label || '—'} + {capabilityList.length + ? capabilityList.map((value) => formatCapabilityLabel(value)).join(', ') + : '—'} + {isSystem ? 'Yes' : 'No'}{typeof set.cap_version === 'number' ? set.cap_version : '—'} + {isSystem ? ( + System set + ) : ( + <> + + + + )} +
+
+
+ + setEditCapabilitySetSlug(event.target.value)} + disabled={saving || capabilitySetsLoading} + /> +
+ {supportsCapabilitySetLabels ? ( +
+ + setEditCapabilitySetLabel(event.target.value)} + disabled={saving || capabilitySetsLoading} + /> +
+ ) : null} +
+ +
+ + {editCapabilitySetCapabilities.length ? ( +
+ {editCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + + ))} +
+ ) : ( + + No capabilities selected. + + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} + {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( + No capabilities available. + ) : null} +
+
+
+ + +
+
+ {capabilitySetEditError ? ( +

{capabilitySetEditError}

+ ) : null} +
+ ) : null} +
+ ); + }, [ + capabilitySetEditError, + capabilitySetFormError, + capabilitySelectionOptions, + capabilitiesLoading, + capabilitySets, + capabilitySetsLoading, + creatingCapabilitySet, + deletingCapabilitySetId, + editCapabilitySetCapabilities, + editCapabilitySetLabel, + editCapabilitySetSlug, + editingCapabilitySetId, + handleCapabilitySetsRefresh, + handleAddCapabilityToEditSet, + handleAddCapabilityToNewSet, + handleCancelEditCapabilitySet, + handleCreateCapabilitySetSubmit, + handleDeleteCapabilitySet, + handleRemoveCapabilityFromEditSet, + handleRemoveCapabilityFromNewSet, + handleStartEditCapabilitySet, + handleUpdateCapabilitySetSubmit, + newCapabilitySetCapabilities, + newCapabilitySetLabel, + newCapabilitySetSlug, + formatCapabilityLabel, + sortCapabilityValues, + savingCapabilitySetId, + supportsCapabilitySetLabels, + ]); + const renderApiTokensSection = useMemo(() => { const hasTokens = Array.isArray(tokens) && tokens.length > 0; @@ -271,7 +1007,7 @@ const SettingsModal = ({ onChange={(event) => setNewTokenExpires(event.target.value)} />
-
+