ui
This commit is contained in:
+31
-187
@@ -257,12 +257,6 @@ const AppLayout = () => {
|
||||
const previewReturnPathRef = useRef(null);
|
||||
const [tags, setTags] = useState([]);
|
||||
const [correspondents, setCorrespondents] = useState([]);
|
||||
const [webdavTokens, setWebdavTokens] = useState([]);
|
||||
const [webdavTokensLoading, setWebdavTokensLoading] = useState(false);
|
||||
const [creatingWebdavToken, setCreatingWebdavToken] = useState(false);
|
||||
const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null);
|
||||
const [regeneratingWebdavTokenId, setRegeneratingWebdavTokenId] = useState(null);
|
||||
const [webdavTokenSecret, setWebdavTokenSecret] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
||||
@@ -425,11 +419,6 @@ const AppLayout = () => {
|
||||
setSearchResults(null);
|
||||
setTags([]);
|
||||
setCorrespondents([]);
|
||||
setWebdavTokens([]);
|
||||
setWebdavTokensLoading(false);
|
||||
setCreatingWebdavToken(false);
|
||||
setDeletingWebdavTokenId(null);
|
||||
setWebdavTokenSecret(null);
|
||||
setSearchQuery('');
|
||||
setActiveTagFilters([]);
|
||||
setActiveCorrespondentFilters([]);
|
||||
@@ -927,32 +916,10 @@ const AppLayout = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentRowClick = useCallback(
|
||||
(documentId, event) => {
|
||||
const rowKey = resolveDocumentRowKey(documentId);
|
||||
if (!rowKey) return;
|
||||
const hadSelection = selectionCount > 0;
|
||||
handleRowSelection(rowKey, event);
|
||||
if (!hadSelection) {
|
||||
detailPanelControlRef.current.open();
|
||||
}
|
||||
},
|
||||
[handleRowSelection, selectionCount],
|
||||
);
|
||||
|
||||
const clearDocumentSelection = useCallback(() => {
|
||||
clearSelectionInternal();
|
||||
}, [clearSelectionInternal]);
|
||||
|
||||
const handleFolderRowClick = useCallback(
|
||||
(folderId, event) => {
|
||||
const rowKey = resolveFolderRowKey(folderId);
|
||||
if (!rowKey) return;
|
||||
handleRowSelection(rowKey, event);
|
||||
},
|
||||
[handleRowSelection],
|
||||
);
|
||||
|
||||
const prevFocusedDocIdRef = useRef(focusedDocumentId);
|
||||
useEffect(() => {
|
||||
const previous = prevFocusedDocIdRef.current;
|
||||
@@ -968,23 +935,39 @@ const AppLayout = () => {
|
||||
}, [focusedDocumentId, setFocusedRowKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusedRowKey) {
|
||||
if (!navigableRowKeys.length) {
|
||||
if (focusedRowKey) {
|
||||
setFocusedRowKey(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (navigableRowKeys.includes(focusedRowKey)) {
|
||||
|
||||
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null;
|
||||
if (docKey && navigableRowKeys.includes(docKey)) {
|
||||
setFocusedRowKey(docKey);
|
||||
return;
|
||||
}
|
||||
if (navigableRowKeys.length) {
|
||||
setFocusedRowKey(navigableRowKeys[0]);
|
||||
} else {
|
||||
|
||||
const selectedKey = selectedRowKeys.find((key) => navigableRowKeys.includes(key));
|
||||
if (selectedKey) {
|
||||
setFocusedRowKey(selectedKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedRowKey) {
|
||||
setFocusedRowKey(null);
|
||||
}
|
||||
}, [focusedRowKey, navigableRowKeys, focusedDocumentId, setFocusedRowKey]);
|
||||
}, [
|
||||
focusedRowKey,
|
||||
focusedDocumentId,
|
||||
navigableRowKeys,
|
||||
selectedRowKeys,
|
||||
setFocusedRowKey,
|
||||
]);
|
||||
|
||||
|
||||
const ensureFolderData = useCallback(
|
||||
@@ -1303,125 +1286,6 @@ const AppLayout = () => {
|
||||
}
|
||||
}, [notifyApiError]);
|
||||
|
||||
const refreshWebdavTokens = useCallback(async () => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
setWebdavTokensLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/profile/webdav-tokens');
|
||||
setWebdavTokens(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to load WebDAV tokens.');
|
||||
} finally {
|
||||
setWebdavTokensLoading(false);
|
||||
}
|
||||
}, [notifyApiError, token]);
|
||||
|
||||
const createWebdavToken = useCallback(
|
||||
async ({ label, expires_at } = {}) => {
|
||||
if (creatingWebdavToken) {
|
||||
return false;
|
||||
}
|
||||
setCreatingWebdavToken(true);
|
||||
try {
|
||||
const payload = {};
|
||||
if (label) {
|
||||
payload.label = label;
|
||||
}
|
||||
if (expires_at) {
|
||||
payload.expires_at = expires_at;
|
||||
}
|
||||
const { data } = await api.post('/profile/webdav-tokens', payload);
|
||||
if (data?.token_info) {
|
||||
setWebdavTokens((previous) => {
|
||||
const filtered = previous.filter((entry) => entry.id !== data.token_info.id);
|
||||
return [data.token_info, ...filtered];
|
||||
});
|
||||
} else {
|
||||
await refreshWebdavTokens();
|
||||
}
|
||||
if (data?.token) {
|
||||
setWebdavTokenSecret(data.token);
|
||||
}
|
||||
setStatusMessage('WebDAV token created.', 'success');
|
||||
return data;
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to create WebDAV token.');
|
||||
return false;
|
||||
} finally {
|
||||
setCreatingWebdavToken(false);
|
||||
}
|
||||
},
|
||||
[creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage],
|
||||
);
|
||||
|
||||
const deleteWebdavToken = useCallback(
|
||||
async (tokenId) => {
|
||||
if (!tokenId) {
|
||||
return false;
|
||||
}
|
||||
setDeletingWebdavTokenId(tokenId);
|
||||
try {
|
||||
await api.delete(`/profile/webdav-tokens/${tokenId}`);
|
||||
await refreshWebdavTokens();
|
||||
setStatusMessage('WebDAV token revoked.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to revoke WebDAV token.');
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingWebdavTokenId(null);
|
||||
}
|
||||
},
|
||||
[refreshWebdavTokens, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const regenerateWebdavToken = useCallback(
|
||||
async (tokenId) => {
|
||||
if (!tokenId) {
|
||||
return false;
|
||||
}
|
||||
setRegeneratingWebdavTokenId(tokenId);
|
||||
try {
|
||||
const { data } = await api.post(`/profile/webdav-tokens/${tokenId}/regenerate`);
|
||||
if (data?.token_info) {
|
||||
setWebdavTokens((previous) => {
|
||||
let found = false;
|
||||
const next = previous.map((entry) => {
|
||||
if (entry.id === data.token_info.id) {
|
||||
found = true;
|
||||
return data.token_info;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
if (!found) {
|
||||
return [data.token_info, ...previous];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
await refreshWebdavTokens();
|
||||
}
|
||||
if (data?.token) {
|
||||
setWebdavTokenSecret(data.token);
|
||||
}
|
||||
setStatusMessage('WebDAV token regenerated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to regenerate WebDAV token.');
|
||||
return false;
|
||||
} finally {
|
||||
setRegeneratingWebdavTokenId(null);
|
||||
}
|
||||
},
|
||||
[notifyApiError, refreshWebdavTokens, setStatusMessage],
|
||||
);
|
||||
|
||||
const dismissCreatedWebdavToken = useCallback(() => {
|
||||
setWebdavTokenSecret(null);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
@@ -3208,9 +3072,13 @@ const AppLayout = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedRowKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedRowKey(resolvedKey);
|
||||
|
||||
if (!selectedRowKeys.includes(resolvedKey)) {
|
||||
if (!selectedRowKeys.includes(resolvedKey) && selectedRowKeys.length > 0) {
|
||||
applySelection([resolvedKey], { anchor: resolvedKey, interactedKeys: [resolvedKey] });
|
||||
}
|
||||
}, [
|
||||
@@ -4699,15 +4567,12 @@ const AppLayout = () => {
|
||||
onFolderDragEnd: handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
onFolderDelete: handleFolderDelete,
|
||||
onFolderRowClick: handleFolderRowClick,
|
||||
onFolderRename: handleFolderRename,
|
||||
onDocumentRowClick: handleDocumentRowClick,
|
||||
onDocumentOpen: openDocumentPreview,
|
||||
onDocumentDelete: handleDocumentDelete,
|
||||
onDocumentRename: handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedDocumentId,
|
||||
focusedRowKey,
|
||||
draggingDocumentIds: draggedDocumentIds,
|
||||
onDocumentDragStart: handleDocumentDragStart,
|
||||
@@ -4730,6 +4595,8 @@ const AppLayout = () => {
|
||||
viewMode: documentsViewMode,
|
||||
onViewModeChange: handleDocumentsViewModeChange,
|
||||
onClearSelection: clearDocumentSelection,
|
||||
onRowSelection: handleRowSelection,
|
||||
onOpenDetailPanel: openDetailPanel,
|
||||
}),
|
||||
[
|
||||
activeCorrespondentFilters,
|
||||
@@ -4741,7 +4608,6 @@ const AppLayout = () => {
|
||||
documentsViewMode,
|
||||
draggedDocumentIds,
|
||||
draggedFolderId,
|
||||
focusedDocumentId,
|
||||
focusedRowKey,
|
||||
folderClickHandlers,
|
||||
handleDocumentDelete,
|
||||
@@ -4749,7 +4615,6 @@ const AppLayout = () => {
|
||||
handleDocumentDragStart,
|
||||
handleDocumentListFocus,
|
||||
handleDocumentListKeyDown,
|
||||
handleDocumentRowClick,
|
||||
handleDocumentTagDrop,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentsViewModeChange,
|
||||
@@ -4757,8 +4622,9 @@ const AppLayout = () => {
|
||||
handleFolderDragEnd,
|
||||
handleFolderDragStart,
|
||||
handleFolderRename,
|
||||
handleFolderRowClick,
|
||||
handleRowSelection,
|
||||
isFilterActive,
|
||||
openDetailPanel,
|
||||
openDocumentPreview,
|
||||
refreshCurrentFolder,
|
||||
searchLoading,
|
||||
@@ -4975,17 +4841,6 @@ const AppLayout = () => {
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
webdavTokens,
|
||||
webdavTokensLoading,
|
||||
creatingWebdavToken,
|
||||
deletingWebdavTokenId,
|
||||
regeneratingWebdavTokenId,
|
||||
refreshWebdavTokens,
|
||||
createWebdavToken,
|
||||
deleteWebdavToken,
|
||||
regenerateWebdavToken,
|
||||
webdavTokenSecret,
|
||||
dismissCreatedWebdavToken,
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
@@ -5036,17 +4891,6 @@ const AppLayout = () => {
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
webdavTokens,
|
||||
webdavTokensLoading,
|
||||
creatingWebdavToken,
|
||||
deletingWebdavTokenId,
|
||||
regeneratingWebdavTokenId,
|
||||
refreshWebdavTokens,
|
||||
createWebdavToken,
|
||||
deleteWebdavToken,
|
||||
regenerateWebdavToken,
|
||||
webdavTokenSecret,
|
||||
dismissCreatedWebdavToken,
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
|
||||
@@ -2,21 +2,15 @@ import React, { useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SettingsModal from '../settings/SettingsModal';
|
||||
import { useAppShell } from '../appShellContext';
|
||||
import useApiTokens from '../settings/useApiTokens';
|
||||
import { api } from './appState';
|
||||
|
||||
const SettingsRoute = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
webdavTokens,
|
||||
webdavTokensLoading,
|
||||
creatingWebdavToken,
|
||||
deletingWebdavTokenId,
|
||||
regeneratingWebdavTokenId,
|
||||
refreshWebdavTokens,
|
||||
createWebdavToken,
|
||||
deleteWebdavToken,
|
||||
regenerateWebdavToken,
|
||||
webdavTokenSecret,
|
||||
dismissCreatedWebdavToken,
|
||||
token,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
@@ -27,15 +21,31 @@ const SettingsRoute = () => {
|
||||
revokePasskey,
|
||||
} = useAppShell();
|
||||
|
||||
const {
|
||||
tokens,
|
||||
loading,
|
||||
creating,
|
||||
deletingId,
|
||||
regeneratingId,
|
||||
updatingId,
|
||||
createdSecret,
|
||||
refresh,
|
||||
create,
|
||||
revoke,
|
||||
regenerate,
|
||||
updateCapabilities,
|
||||
dismissSecret,
|
||||
} = useApiTokens({ api, token, notifyApiError, setStatusMessage });
|
||||
|
||||
useEffect(() => {
|
||||
refreshWebdavTokens();
|
||||
refresh();
|
||||
refreshPasskeys();
|
||||
}, [refreshWebdavTokens, refreshPasskeys]);
|
||||
}, [refresh, refreshPasskeys]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dismissCreatedWebdavToken();
|
||||
dismissSecret();
|
||||
navigate(-1);
|
||||
}, [dismissCreatedWebdavToken, navigate]);
|
||||
}, [dismissSecret, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
@@ -49,24 +59,26 @@ const SettingsRoute = () => {
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => () => {
|
||||
dismissCreatedWebdavToken();
|
||||
}, [dismissCreatedWebdavToken]);
|
||||
dismissSecret();
|
||||
}, [dismissSecret]);
|
||||
|
||||
return (
|
||||
<SettingsModal
|
||||
open
|
||||
onClose={handleClose}
|
||||
tokens={webdavTokens}
|
||||
loading={webdavTokensLoading}
|
||||
creating={creatingWebdavToken}
|
||||
deletingId={deletingWebdavTokenId}
|
||||
regeneratingId={regeneratingWebdavTokenId}
|
||||
onRefresh={refreshWebdavTokens}
|
||||
onCreate={createWebdavToken}
|
||||
onDelete={deleteWebdavToken}
|
||||
onRegenerate={regenerateWebdavToken}
|
||||
createdToken={webdavTokenSecret}
|
||||
onDismissCreatedToken={dismissCreatedWebdavToken}
|
||||
tokens={tokens}
|
||||
loading={loading}
|
||||
creating={creating}
|
||||
deletingId={deletingId}
|
||||
regeneratingId={regeneratingId}
|
||||
updatingId={updatingId}
|
||||
onRefresh={refresh}
|
||||
onCreate={create}
|
||||
onDelete={revoke}
|
||||
onRegenerate={regenerate}
|
||||
onUpdateCapabilities={updateCapabilities}
|
||||
createdToken={createdSecret}
|
||||
onDismissCreatedToken={dismissSecret}
|
||||
passkeys={passkeys}
|
||||
passkeysSupported={passkeysSupported}
|
||||
passkeysLoading={passkeysLoading}
|
||||
|
||||
@@ -12,24 +12,32 @@ export const useDetailPanel = ({
|
||||
const [detailPanelDocIds, setDetailPanelDocIds] = useState([]);
|
||||
const [detailPanelDocs, setDetailPanelDocs] = useState([]);
|
||||
const lastScrolledDetailDocRef = useRef(null);
|
||||
const latestOrderedDocsRef = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderedSelectedDocuments.length) {
|
||||
setDetailPanelDocIds((prev) => (prev.length ? [] : prev));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIds = orderedSelectedDocuments
|
||||
.map((doc) => doc?.id)
|
||||
.filter((id) => typeof id === 'string' || typeof id === 'number');
|
||||
|
||||
setDetailPanelDocIds((prev) => {
|
||||
if (prev.length === nextIds.length && prev.every((id, index) => id === nextIds[index])) {
|
||||
return prev;
|
||||
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
||||
if (detailPanelOpen && orderedSelectedDocuments.length) {
|
||||
const snapshotIds = orderedSelectedDocuments
|
||||
.map((doc) => doc?.id)
|
||||
.filter((id) => typeof id === 'string' || typeof id === 'number');
|
||||
if (snapshotIds.length) {
|
||||
setDetailPanelDocIds(snapshotIds);
|
||||
}
|
||||
return nextIds;
|
||||
});
|
||||
}, [orderedSelectedDocuments]);
|
||||
}
|
||||
}, [orderedSelectedDocuments, detailPanelOpen]);
|
||||
|
||||
const resolveDocsForIds = useCallback(
|
||||
(ids, fallbackDocs = []) => {
|
||||
if (!ids?.length) {
|
||||
return [];
|
||||
}
|
||||
const fallbackMap = new Map((fallbackDocs || []).map((doc) => [doc?.id, doc]));
|
||||
return ids
|
||||
.map((id) => documentLookup.get(id) || fallbackMap.get(id) || null)
|
||||
.filter(Boolean);
|
||||
},
|
||||
[documentLookup],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailPanelDocIds.length) {
|
||||
@@ -38,17 +46,13 @@ export const useDetailPanel = ({
|
||||
}
|
||||
|
||||
setDetailPanelDocs((prevDocs) => {
|
||||
const prevMap = new Map((prevDocs || []).map((doc) => [doc.id, doc]));
|
||||
const next = detailPanelDocIds
|
||||
.map((id) => documentLookup.get(id) || prevMap.get(id) || null)
|
||||
.filter(Boolean);
|
||||
|
||||
if (next.length === prevDocs.length && next.every((doc, index) => doc === prevDocs[index])) {
|
||||
const resolved = resolveDocsForIds(detailPanelDocIds, prevDocs);
|
||||
if (resolved.length === prevDocs.length && resolved.every((doc, index) => doc === prevDocs[index])) {
|
||||
return prevDocs;
|
||||
}
|
||||
return next;
|
||||
return resolved;
|
||||
});
|
||||
}, [detailPanelDocIds, documentLookup]);
|
||||
}, [detailPanelDocIds, resolveDocsForIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailPanelOpen) {
|
||||
@@ -108,9 +112,39 @@ export const useDetailPanel = ({
|
||||
|
||||
const detailPanelSelectedDocuments = detailPanelDocs;
|
||||
|
||||
const openDetailPanel = useCallback(() => {
|
||||
setDetailPanelOpen(true);
|
||||
}, []);
|
||||
const openDetailPanel = useCallback(
|
||||
({ documentIds: explicitIds, documents: explicitDocs } = {}) => {
|
||||
let sourceDocs = Array.isArray(explicitDocs) ? explicitDocs : null;
|
||||
let snapshotIds = Array.isArray(explicitIds)
|
||||
? explicitIds.filter((id) => typeof id === 'string' || typeof id === 'number')
|
||||
: null;
|
||||
|
||||
if (!snapshotIds?.length) {
|
||||
if (!sourceDocs || !sourceDocs.length) {
|
||||
sourceDocs = latestOrderedDocsRef.current;
|
||||
}
|
||||
snapshotIds = Array.isArray(sourceDocs)
|
||||
? sourceDocs
|
||||
.map((doc) => doc?.id)
|
||||
.filter((id) => typeof id === 'string' || typeof id === 'number')
|
||||
: [];
|
||||
}
|
||||
|
||||
const uniqueIds = [];
|
||||
snapshotIds.forEach((id) => {
|
||||
if (!uniqueIds.includes(id)) {
|
||||
uniqueIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
const resolvedDocs = resolveDocsForIds(uniqueIds, sourceDocs || latestOrderedDocsRef.current);
|
||||
|
||||
setDetailPanelDocIds(uniqueIds);
|
||||
setDetailPanelDocs(resolvedDocs);
|
||||
setDetailPanelOpen(true);
|
||||
},
|
||||
[resolveDocsForIds],
|
||||
);
|
||||
|
||||
const closeDetailPanel = useCallback(() => {
|
||||
setDetailPanelOpen(false);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ const SECTIONS = [
|
||||
label: 'Passkeys',
|
||||
},
|
||||
{
|
||||
id: 'webdav',
|
||||
label: 'WebDAV',
|
||||
id: 'apiTokens',
|
||||
label: 'API tokens',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,10 +20,12 @@ const SettingsModal = ({
|
||||
creating = false,
|
||||
deletingId = null,
|
||||
regeneratingId = null,
|
||||
updatingId = null,
|
||||
onRefresh,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
onUpdateCapabilities,
|
||||
createdToken = null,
|
||||
onDismissCreatedToken,
|
||||
passkeys = [],
|
||||
@@ -39,6 +41,7 @@ const SettingsModal = ({
|
||||
const [activeSection, setActiveSection] = useState(defaultSection);
|
||||
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||
const [newTokenCapabilities, setNewTokenCapabilities] = useState([]);
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
||||
|
||||
@@ -55,6 +58,7 @@ const SettingsModal = ({
|
||||
setActiveSection(defaultSection);
|
||||
setNewTokenLabel('');
|
||||
setNewTokenExpires('');
|
||||
setNewTokenCapabilities([]);
|
||||
setFormError(null);
|
||||
setNewPasskeyNickname('');
|
||||
}
|
||||
@@ -116,6 +120,57 @@ const SettingsModal = ({
|
||||
[onRevokePasskey],
|
||||
);
|
||||
|
||||
const capabilityOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'webdav', label: 'WebDAV access' },
|
||||
{ value: 'api', label: 'REST API access' },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNewCapabilityChange = useCallback((capability, enabled) => {
|
||||
setFormError(null);
|
||||
setNewTokenCapabilities((previous) => {
|
||||
if (enabled) {
|
||||
if (previous.includes(capability)) {
|
||||
return previous;
|
||||
}
|
||||
return [...previous, capability];
|
||||
}
|
||||
return previous.filter((value) => value !== capability);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleTokenCapability = useCallback(
|
||||
async (token, capability, enabled) => {
|
||||
if (!token?.id || !onUpdateCapabilities) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = Array.isArray(token.capabilities) ? [...token.capabilities] : [];
|
||||
let next;
|
||||
if (enabled) {
|
||||
if (existing.includes(capability)) {
|
||||
return;
|
||||
}
|
||||
next = [...existing, capability];
|
||||
} else {
|
||||
next = existing.filter((value) => value !== capability);
|
||||
if (next.length === 0) {
|
||||
setFormError('Tokens must have at least one capability.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFormError(null);
|
||||
const result = await onUpdateCapabilities(token.id, next);
|
||||
if (result === false) {
|
||||
setFormError('Failed to update token capabilities.');
|
||||
}
|
||||
},
|
||||
[onUpdateCapabilities],
|
||||
);
|
||||
|
||||
const handleCreateToken = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -135,18 +190,25 @@ const SettingsModal = ({
|
||||
normalizedExpires = parsed.toISOString();
|
||||
}
|
||||
|
||||
if (!newTokenCapabilities.length) {
|
||||
setFormError('Select at least one capability.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await onCreate?.({
|
||||
label: normalizedLabel,
|
||||
expires_at: normalizedExpires,
|
||||
capabilities: newTokenCapabilities,
|
||||
});
|
||||
|
||||
if (result !== false) {
|
||||
setNewTokenLabel('');
|
||||
setNewTokenExpires('');
|
||||
setNewTokenCapabilities([]);
|
||||
setFormError(null);
|
||||
}
|
||||
},
|
||||
[newTokenExpires, newTokenLabel, onCreate],
|
||||
[newTokenExpires, newTokenLabel, newTokenCapabilities, onCreate],
|
||||
);
|
||||
|
||||
const handleRegenerateToken = useCallback(
|
||||
@@ -159,7 +221,7 @@ const SettingsModal = ({
|
||||
[onRegenerate],
|
||||
);
|
||||
|
||||
const renderWebdavSection = useMemo(() => {
|
||||
const renderApiTokensSection = useMemo(() => {
|
||||
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
||||
|
||||
return (
|
||||
@@ -175,6 +237,11 @@ const SettingsModal = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
API tokens can grant access to the REST API, WebDAV, or both. Select at least one
|
||||
capability for each token. You can adjust capabilities for existing tokens at any time.
|
||||
</p>
|
||||
|
||||
{createdToken ? (
|
||||
<div className="settings-notice">
|
||||
<p>
|
||||
@@ -194,24 +261,44 @@ const SettingsModal = ({
|
||||
|
||||
<form className="settings-form" onSubmit={handleCreateToken}>
|
||||
<div className="settings-form__field">
|
||||
<label htmlFor="webdav-token-label">Label</label>
|
||||
<label htmlFor="api-token-label">Label</label>
|
||||
<input
|
||||
id="webdav-token-label"
|
||||
id="api-token-label"
|
||||
type="text"
|
||||
value={newTokenLabel}
|
||||
onChange={(event) => setNewTokenLabel(event.target.value)}
|
||||
placeholder="Personal WebDAV token"
|
||||
placeholder="Personal API token"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-form__field">
|
||||
<label htmlFor="webdav-token-expires">Expires at</label>
|
||||
<label htmlFor="api-token-expires">Expires at</label>
|
||||
<input
|
||||
id="webdav-token-expires"
|
||||
id="api-token-expires"
|
||||
type="datetime-local"
|
||||
value={newTokenExpires}
|
||||
onChange={(event) => setNewTokenExpires(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="settings-form__field">
|
||||
<legend>Capabilities</legend>
|
||||
<div className="settings-form__choices">
|
||||
{capabilityOptions.map((option) => {
|
||||
const checked = newTokenCapabilities.includes(option.value);
|
||||
return (
|
||||
<label key={option.value} className="settings-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) =>
|
||||
handleNewCapabilityChange(option.value, event.target.checked)
|
||||
}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="settings-form__actions">
|
||||
<button type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create token'}
|
||||
@@ -225,7 +312,7 @@ const SettingsModal = ({
|
||||
) : null}
|
||||
|
||||
{!loading && !hasTokens ? (
|
||||
<p className="settings-empty">No WebDAV tokens yet.</p>
|
||||
<p className="settings-empty">No API tokens yet.</p>
|
||||
) : null}
|
||||
|
||||
{hasTokens ? (
|
||||
@@ -236,18 +323,54 @@ const SettingsModal = ({
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Last used</th>
|
||||
<th scope="col">Expires</th>
|
||||
<th scope="col">Capabilities</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => {
|
||||
const isRevoked = Boolean(token?.revoked_at);
|
||||
const capabilitySet = Array.isArray(token?.capabilities)
|
||||
? token.capabilities
|
||||
: [];
|
||||
return (
|
||||
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
||||
<td>{token.label || '—'}</td>
|
||||
<td>{formatDateTime(token.created_at)}</td>
|
||||
<td>{formatDateTime(token.last_used_at)}</td>
|
||||
<td>{formatDateTime(token.expires_at)}</td>
|
||||
<td>
|
||||
<div className="settings-form__choices">
|
||||
{capabilityOptions.map((option) => {
|
||||
const checked = capabilitySet.includes(option.value);
|
||||
return (
|
||||
<label key={option.value} className="settings-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={
|
||||
isRevoked
|
||||
|| deletingId === token.id
|
||||
|| regeneratingId === token.id
|
||||
|| updatingId === token.id
|
||||
}
|
||||
onChange={(event) =>
|
||||
handleToggleTokenCapability(
|
||||
token,
|
||||
option.value,
|
||||
event.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{updatingId === token.id ? (
|
||||
<span className="settings-status">Saving…</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="settings-table__actions">
|
||||
{isRevoked ? (
|
||||
<span className="settings-status">Revoked</span>
|
||||
@@ -291,9 +414,12 @@ const SettingsModal = ({
|
||||
creating,
|
||||
deletingId,
|
||||
regeneratingId,
|
||||
updatingId,
|
||||
newTokenLabel,
|
||||
newTokenExpires,
|
||||
newTokenCapabilities,
|
||||
formError,
|
||||
capabilityOptions,
|
||||
formatDateTime,
|
||||
handleCopyToken,
|
||||
handleCreateToken,
|
||||
@@ -301,6 +427,8 @@ const SettingsModal = ({
|
||||
handleRefresh,
|
||||
onDelete,
|
||||
handleDismissSecret,
|
||||
handleNewCapabilityChange,
|
||||
handleToggleTokenCapability,
|
||||
]);
|
||||
|
||||
const renderPasskeysSection = useMemo(() => {
|
||||
@@ -465,8 +593,8 @@ const SettingsModal = ({
|
||||
</nav>
|
||||
<div className="settings-modal__content">
|
||||
{activeSection === 'passkeys' ? renderPasskeysSection : null}
|
||||
{activeSection === 'webdav' ? renderWebdavSection : null}
|
||||
{activeSection !== 'passkeys' && activeSection !== 'webdav' ? (
|
||||
{activeSection === 'apiTokens' ? renderApiTokensSection : null}
|
||||
{activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? (
|
||||
<p>Select a settings section.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1883,6 +1883,12 @@ button.danger:hover:not([disabled]) {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.documents-panel .documents-scroll:focus-visible {
|
||||
outline: 2px solid var(--selection-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.documents-panel table {
|
||||
max-width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -3129,6 +3135,19 @@ form.inline {
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
fieldset.settings-form__field {
|
||||
border: 1px solid var(--border-muted, var(--border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 0.9rem 0.85rem;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
fieldset.settings-form__field legend {
|
||||
padding: 0 0.35rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-strong, inherit);
|
||||
}
|
||||
|
||||
.settings-form__field input[type='text'],
|
||||
.settings-form__field input[type='datetime-local'] {
|
||||
padding: 0.45rem 0.6rem;
|
||||
@@ -3138,6 +3157,33 @@ form.inline {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.settings-form__choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.settings-choice input[type='checkbox'] {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-choice input[type='checkbox']:disabled + span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-form__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
Reference in New Issue
Block a user