From e9c5f27b17ea8b3159002004b7eeebd6d0a0c5cd Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 5 Nov 2025 15:21:02 +0100 Subject: [PATCH] capsapi --- frontend/src/app/SettingsRoute.jsx | 19 +- frontend/src/settings/SettingsModal.jsx | 235 ++++++++++++------------ frontend/src/settings/useApiTokens.js | 81 ++++---- 3 files changed, 165 insertions(+), 170 deletions(-) diff --git a/frontend/src/app/SettingsRoute.jsx b/frontend/src/app/SettingsRoute.jsx index ad6b8ac..c6f6665 100644 --- a/frontend/src/app/SettingsRoute.jsx +++ b/frontend/src/app/SettingsRoute.jsx @@ -27,20 +27,27 @@ const SettingsRoute = () => { creating, deletingId, regeneratingId, - updatingId, createdSecret, refresh, + refreshCapabilitySets, create, revoke, regenerate, - updateCapabilities, dismissSecret, + capabilitySets, + capabilitySetsLoading, } = useApiTokens({ api, token, notifyApiError, setStatusMessage }); useEffect(() => { refresh(); + refreshCapabilitySets(); refreshPasskeys(); - }, [refresh, refreshPasskeys]); + }, [refresh, refreshCapabilitySets, refreshPasskeys]); + + const handleRefresh = useCallback(() => { + refresh(); + refreshCapabilitySets(); + }, [refresh, refreshCapabilitySets]); const handleClose = useCallback(() => { dismissSecret(); @@ -71,14 +78,14 @@ const SettingsRoute = () => { creating={creating} deletingId={deletingId} regeneratingId={regeneratingId} - updatingId={updatingId} - onRefresh={refresh} + onRefresh={handleRefresh} onCreate={create} onDelete={revoke} onRegenerate={regenerate} - onUpdateCapabilities={updateCapabilities} createdToken={createdSecret} onDismissCreatedToken={dismissSecret} + capabilitySets={capabilitySets} + capabilitySetsLoading={capabilitySetsLoading} passkeys={passkeys} passkeysSupported={passkeysSupported} passkeysLoading={passkeysLoading} diff --git a/frontend/src/settings/SettingsModal.jsx b/frontend/src/settings/SettingsModal.jsx index 3860394..4755734 100644 --- a/frontend/src/settings/SettingsModal.jsx +++ b/frontend/src/settings/SettingsModal.jsx @@ -20,12 +20,12 @@ const SettingsModal = ({ creating = false, deletingId = null, regeneratingId = null, - updatingId = null, + capabilitySets = [], + capabilitySetsLoading = false, onRefresh, onCreate, onDelete, onRegenerate, - onUpdateCapabilities, createdToken = null, onDismissCreatedToken, passkeys = [], @@ -41,7 +41,7 @@ const SettingsModal = ({ const [activeSection, setActiveSection] = useState(defaultSection); const [newTokenLabel, setNewTokenLabel] = useState(''); const [newTokenExpires, setNewTokenExpires] = useState(''); - const [newTokenCapabilities, setNewTokenCapabilities] = useState([]); + const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState(''); const [formError, setFormError] = useState(null); const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); @@ -58,7 +58,7 @@ const SettingsModal = ({ setActiveSection(defaultSection); setNewTokenLabel(''); setNewTokenExpires(''); - setNewTokenCapabilities([]); + setNewTokenCapabilitySetId(''); setFormError(null); setNewPasskeyNickname(''); } @@ -120,57 +120,36 @@ const SettingsModal = ({ [onRevokePasskey], ); - const capabilityOptions = useMemo( - () => [ - { value: 'webdav', label: 'WebDAV access' }, - { value: 'api', label: 'REST API access' }, - ], - [], + const capabilitySetOptions = useMemo( + () => capabilitySets.map((set) => ({ + value: set.id, + label: set.label || set.slug || set.id, + capabilities: Array.isArray(set.capabilities) ? set.capabilities : [], + })), + [capabilitySets], ); - const handleNewCapabilityChange = useCallback((capability, enabled) => { + const capabilitySetMap = useMemo( + () => Object.fromEntries(capabilitySetOptions.map((option) => [option.value, option])), + [capabilitySetOptions], + ); + + useEffect(() => { + if (!capabilitySetOptions.length) { + setNewTokenCapabilitySetId(''); + return; + } + if (!newTokenCapabilitySetId + || !capabilitySetOptions.some((option) => option.value === newTokenCapabilitySetId)) { + setNewTokenCapabilitySetId(capabilitySetOptions[0].value); + } + }, [capabilitySetOptions, newTokenCapabilitySetId]); + + const handleNewCapabilitySetChange = useCallback((event) => { setFormError(null); - setNewTokenCapabilities((previous) => { - if (enabled) { - if (previous.includes(capability)) { - return previous; - } - return [...previous, capability]; - } - return previous.filter((value) => value !== capability); - }); + setNewTokenCapabilitySetId(event.target.value); }, []); - 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(); @@ -190,25 +169,38 @@ const SettingsModal = ({ normalizedExpires = parsed.toISOString(); } - if (!newTokenCapabilities.length) { - setFormError('Select at least one capability.'); + if (!capabilitySetOptions.length) { + setFormError('Capability sets are still loading.'); + return; + } + + const selectedCapabilitySetId = newTokenCapabilitySetId + || capabilitySetOptions[0]?.value; + if (!selectedCapabilitySetId) { + setFormError('Select a capability set.'); return; } const result = await onCreate?.({ label: normalizedLabel, expires_at: normalizedExpires, - capabilities: newTokenCapabilities, + capability_set_id: selectedCapabilitySetId, }); if (result !== false) { setNewTokenLabel(''); setNewTokenExpires(''); - setNewTokenCapabilities([]); + setNewTokenCapabilitySetId(capabilitySetOptions[0]?.value || ''); setFormError(null); } }, - [newTokenExpires, newTokenLabel, newTokenCapabilities, onCreate], + [ + capabilitySetOptions, + newTokenCapabilitySetId, + newTokenExpires, + newTokenLabel, + onCreate, + ], ); const handleRegenerateToken = useCallback( @@ -231,15 +223,15 @@ const SettingsModal = ({ type="button" className="secondary" onClick={handleRefresh} - disabled={loading} + disabled={loading || capabilitySetsLoading} > - {loading ? 'Refreshing…' : 'Refresh'} + {loading || capabilitySetsLoading ? 'Refreshing…' : 'Refresh'}

- 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. + API tokens use predefined capability sets. Choose the set that matches the access you + need when creating or updating a token.

{createdToken ? ( @@ -279,28 +271,49 @@ const SettingsModal = ({ onChange={(event) => setNewTokenExpires(event.target.value)} /> -
- Capabilities -
- {capabilityOptions.map((option) => { - const checked = newTokenCapabilities.includes(option.value); - return ( - - ); - })} -
-
+
+ + + {capabilitySetsLoading ? ( + Loading capability sets… + ) : null} + {!capabilitySetsLoading && !capabilitySetOptions.length ? ( + No capability sets available. + ) : null} + {!capabilitySetsLoading + && capabilitySetMap[newTokenCapabilitySetId]?.capabilities?.length ? ( + + Includes: {capabilitySetMap[newTokenCapabilitySetId].capabilities.join(', ')} + + ) : null} +
-
@@ -323,16 +336,23 @@ const SettingsModal = ({ Created Last used Expires - Capabilities + Capability set Actions {tokens.map((token) => { const isRevoked = Boolean(token?.revoked_at); - const capabilitySet = Array.isArray(token?.capabilities) + const selectedSet = token?.capability_set_id + ? capabilitySetMap[token.capability_set_id] + : null; + const capabilityList = Array.isArray(token?.capabilities) && token.capabilities.length ? token.capabilities - : []; + : selectedSet?.capabilities || []; + const capabilitySetLabel = selectedSet?.label + || selectedSet?.slug + || token.capability_set_id + || '—'; return ( {token.label || '—'} @@ -340,36 +360,17 @@ const SettingsModal = ({ {formatDateTime(token.last_used_at)} {formatDateTime(token.expires_at)} -
- {capabilityOptions.map((option) => { - const checked = capabilitySet.includes(option.value); - return ( - - ); - })} - {updatingId === token.id ? ( - Saving… - ) : null} +
+ {capabilitySetLabel}
+ {capabilitySetsLoading ? ( + Loading capability sets… + ) : null} + {!capabilitySetsLoading && capabilityList.length ? ( +
+ Includes: {capabilityList.join(', ')} +
+ ) : null} {isRevoked ? ( @@ -414,12 +415,13 @@ const SettingsModal = ({ creating, deletingId, regeneratingId, - updatingId, newTokenLabel, newTokenExpires, - newTokenCapabilities, + newTokenCapabilitySetId, formError, - capabilityOptions, + capabilitySetOptions, + capabilitySetMap, + capabilitySetsLoading, formatDateTime, handleCopyToken, handleCreateToken, @@ -427,8 +429,7 @@ const SettingsModal = ({ handleRefresh, onDelete, handleDismissSecret, - handleNewCapabilityChange, - handleToggleTokenCapability, + handleNewCapabilitySetChange, ]); const renderPasskeysSection = useMemo(() => { diff --git a/frontend/src/settings/useApiTokens.js b/frontend/src/settings/useApiTokens.js index 0b95506..e462d13 100644 --- a/frontend/src/settings/useApiTokens.js +++ b/frontend/src/settings/useApiTokens.js @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { const [tokens, setTokens] = useState([]); @@ -6,8 +6,25 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { const [creating, setCreating] = useState(false); const [deletingId, setDeletingId] = useState(null); const [regeneratingId, setRegeneratingId] = useState(null); - const [updatingId, setUpdatingId] = 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 () => { if (!token) { @@ -25,10 +42,13 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { }, [api, notifyApiError, token]); const create = useCallback( - async ({ label, expires_at, capabilities } = {}) => { + async ({ label, expires_at, capability_set_id } = {}) => { if (creating) { return false; } + if (!capability_set_id) { + return false; + } setCreating(true); try { const payload = {}; @@ -38,9 +58,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { if (expires_at) { payload.expires_at = expires_at; } - if (Array.isArray(capabilities) && capabilities.length > 0) { - payload.capabilities = capabilities; - } + payload.capability_set_id = capability_set_id; const { data } = await api.post('/profile/api-tokens', payload); if (data?.token_info) { @@ -132,45 +150,13 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { [api, notifyApiError, refresh, setStatusMessage], ); - const updateCapabilities = useCallback( - async (tokenId, capabilities) => { - if (!tokenId) { - return false; - } - setUpdatingId(tokenId); - try { - const { data } = await api.patch(`/profile/api-tokens/${tokenId}`, { - capabilities, - }); - if (data) { - setTokens((previous) => { - let found = false; - const next = previous.map((entry) => { - if (entry.id === data.id) { - found = true; - return data; - } - return entry; - }); - if (!found) { - return [data, ...previous]; - } - return next; - }); - } else { - await refresh(); - } - setStatusMessage?.('API token updated.', 'success'); - return true; - } catch (error) { - notifyApiError?.(error, 'Failed to update API token.'); - return false; - } finally { - setUpdatingId(null); - } - }, - [api, notifyApiError, refresh, setStatusMessage], - ); + useEffect(() => { + if (token) { + refreshCapabilitySets(); + } else { + setCapabilitySets([]); + } + }, [token, refreshCapabilitySets]); const dismissSecret = useCallback(() => { setCreatedSecret(null); @@ -182,14 +168,15 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { creating, deletingId, regeneratingId, - updatingId, createdSecret, refresh, create, revoke, regenerate, - updateCapabilities, dismissSecret, + capabilitySets, + capabilitySetsLoading, + refreshCapabilitySets, }; };