This commit is contained in:
2025-11-05 15:21:02 +01:00
parent 7b6afa1128
commit e9c5f27b17
3 changed files with 165 additions and 170 deletions
+13 -6
View File
@@ -27,20 +27,27 @@ const SettingsRoute = () => {
creating, creating,
deletingId, deletingId,
regeneratingId, regeneratingId,
updatingId,
createdSecret, createdSecret,
refresh, refresh,
refreshCapabilitySets,
create, create,
revoke, revoke,
regenerate, regenerate,
updateCapabilities,
dismissSecret, dismissSecret,
capabilitySets,
capabilitySetsLoading,
} = useApiTokens({ api, token, notifyApiError, setStatusMessage }); } = useApiTokens({ api, token, notifyApiError, setStatusMessage });
useEffect(() => { useEffect(() => {
refresh(); refresh();
refreshCapabilitySets();
refreshPasskeys(); refreshPasskeys();
}, [refresh, refreshPasskeys]); }, [refresh, refreshCapabilitySets, refreshPasskeys]);
const handleRefresh = useCallback(() => {
refresh();
refreshCapabilitySets();
}, [refresh, refreshCapabilitySets]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
dismissSecret(); dismissSecret();
@@ -71,14 +78,14 @@ const SettingsRoute = () => {
creating={creating} creating={creating}
deletingId={deletingId} deletingId={deletingId}
regeneratingId={regeneratingId} regeneratingId={regeneratingId}
updatingId={updatingId} onRefresh={handleRefresh}
onRefresh={refresh}
onCreate={create} onCreate={create}
onDelete={revoke} onDelete={revoke}
onRegenerate={regenerate} onRegenerate={regenerate}
onUpdateCapabilities={updateCapabilities}
createdToken={createdSecret} createdToken={createdSecret}
onDismissCreatedToken={dismissSecret} onDismissCreatedToken={dismissSecret}
capabilitySets={capabilitySets}
capabilitySetsLoading={capabilitySetsLoading}
passkeys={passkeys} passkeys={passkeys}
passkeysSupported={passkeysSupported} passkeysSupported={passkeysSupported}
passkeysLoading={passkeysLoading} passkeysLoading={passkeysLoading}
+116 -115
View File
@@ -20,12 +20,12 @@ const SettingsModal = ({
creating = false, creating = false,
deletingId = null, deletingId = null,
regeneratingId = null, regeneratingId = null,
updatingId = null, capabilitySets = [],
capabilitySetsLoading = false,
onRefresh, onRefresh,
onCreate, onCreate,
onDelete, onDelete,
onRegenerate, onRegenerate,
onUpdateCapabilities,
createdToken = null, createdToken = null,
onDismissCreatedToken, onDismissCreatedToken,
passkeys = [], passkeys = [],
@@ -41,7 +41,7 @@ const SettingsModal = ({
const [activeSection, setActiveSection] = useState(defaultSection); const [activeSection, setActiveSection] = useState(defaultSection);
const [newTokenLabel, setNewTokenLabel] = useState(''); const [newTokenLabel, setNewTokenLabel] = useState('');
const [newTokenExpires, setNewTokenExpires] = useState(''); const [newTokenExpires, setNewTokenExpires] = useState('');
const [newTokenCapabilities, setNewTokenCapabilities] = useState([]); const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState('');
const [formError, setFormError] = useState(null); const [formError, setFormError] = useState(null);
const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
@@ -58,7 +58,7 @@ const SettingsModal = ({
setActiveSection(defaultSection); setActiveSection(defaultSection);
setNewTokenLabel(''); setNewTokenLabel('');
setNewTokenExpires(''); setNewTokenExpires('');
setNewTokenCapabilities([]); setNewTokenCapabilitySetId('');
setFormError(null); setFormError(null);
setNewPasskeyNickname(''); setNewPasskeyNickname('');
} }
@@ -120,57 +120,36 @@ const SettingsModal = ({
[onRevokePasskey], [onRevokePasskey],
); );
const capabilityOptions = useMemo( const capabilitySetOptions = useMemo(
() => [ () => capabilitySets.map((set) => ({
{ value: 'webdav', label: 'WebDAV access' }, value: set.id,
{ value: 'api', label: 'REST API access' }, 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); setFormError(null);
setNewTokenCapabilities((previous) => { setNewTokenCapabilitySetId(event.target.value);
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( const handleCreateToken = useCallback(
async (event) => { async (event) => {
event.preventDefault(); event.preventDefault();
@@ -190,25 +169,38 @@ const SettingsModal = ({
normalizedExpires = parsed.toISOString(); normalizedExpires = parsed.toISOString();
} }
if (!newTokenCapabilities.length) { if (!capabilitySetOptions.length) {
setFormError('Select at least one capability.'); setFormError('Capability sets are still loading.');
return;
}
const selectedCapabilitySetId = newTokenCapabilitySetId
|| capabilitySetOptions[0]?.value;
if (!selectedCapabilitySetId) {
setFormError('Select a capability set.');
return; return;
} }
const result = await onCreate?.({ const result = await onCreate?.({
label: normalizedLabel, label: normalizedLabel,
expires_at: normalizedExpires, expires_at: normalizedExpires,
capabilities: newTokenCapabilities, capability_set_id: selectedCapabilitySetId,
}); });
if (result !== false) { if (result !== false) {
setNewTokenLabel(''); setNewTokenLabel('');
setNewTokenExpires(''); setNewTokenExpires('');
setNewTokenCapabilities([]); setNewTokenCapabilitySetId(capabilitySetOptions[0]?.value || '');
setFormError(null); setFormError(null);
} }
}, },
[newTokenExpires, newTokenLabel, newTokenCapabilities, onCreate], [
capabilitySetOptions,
newTokenCapabilitySetId,
newTokenExpires,
newTokenLabel,
onCreate,
],
); );
const handleRegenerateToken = useCallback( const handleRegenerateToken = useCallback(
@@ -231,15 +223,15 @@ const SettingsModal = ({
type="button" type="button"
className="secondary" className="secondary"
onClick={handleRefresh} onClick={handleRefresh}
disabled={loading} disabled={loading || capabilitySetsLoading}
> >
{loading ? 'Refreshing…' : 'Refresh'} {loading || capabilitySetsLoading ? 'Refreshing…' : 'Refresh'}
</button> </button>
</div> </div>
<p> <p>
API tokens can grant access to the REST API, WebDAV, or both. Select at least one API tokens use predefined capability sets. Choose the set that matches the access you
capability for each token. You can adjust capabilities for existing tokens at any time. need when creating or updating a token.
</p> </p>
{createdToken ? ( {createdToken ? (
@@ -279,28 +271,49 @@ const SettingsModal = ({
onChange={(event) => setNewTokenExpires(event.target.value)} onChange={(event) => setNewTokenExpires(event.target.value)}
/> />
</div> </div>
<fieldset className="settings-form__field"> <div className="settings-form__field">
<legend>Capabilities</legend> <label htmlFor="api-token-capability-set">Capability set</label>
<div className="settings-form__choices"> <select
{capabilityOptions.map((option) => { id="api-token-capability-set"
const checked = newTokenCapabilities.includes(option.value); value={newTokenCapabilitySetId}
return ( onChange={handleNewCapabilitySetChange}
<label key={option.value} className="settings-choice"> disabled={
<input creating
type="checkbox" || capabilitySetsLoading
checked={checked} || !capabilitySetOptions.length
onChange={(event) =>
handleNewCapabilityChange(option.value, event.target.checked)
} }
/> >
<span>{option.label}</span> {!capabilitySetOptions.length ? (
</label> <option value="">No capability sets available</option>
); ) : null}
})} {capabilitySetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{capabilitySetsLoading ? (
<small>Loading capability sets</small>
) : null}
{!capabilitySetsLoading && !capabilitySetOptions.length ? (
<small>No capability sets available.</small>
) : null}
{!capabilitySetsLoading
&& capabilitySetMap[newTokenCapabilitySetId]?.capabilities?.length ? (
<small>
Includes: {capabilitySetMap[newTokenCapabilitySetId].capabilities.join(', ')}
</small>
) : null}
</div> </div>
</fieldset>
<div className="settings-form__actions"> <div className="settings-form__actions">
<button type="submit" disabled={creating}> <button
type="submit"
disabled={
creating
|| capabilitySetsLoading
|| !capabilitySetOptions.length
}
>
{creating ? 'Creating…' : 'Create token'} {creating ? 'Creating…' : 'Create token'}
</button> </button>
</div> </div>
@@ -323,16 +336,23 @@ const SettingsModal = ({
<th scope="col">Created</th> <th scope="col">Created</th>
<th scope="col">Last used</th> <th scope="col">Last used</th>
<th scope="col">Expires</th> <th scope="col">Expires</th>
<th scope="col">Capabilities</th> <th scope="col">Capability set</th>
<th scope="col">Actions</th> <th scope="col">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tokens.map((token) => { {tokens.map((token) => {
const isRevoked = Boolean(token?.revoked_at); 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 ? token.capabilities
: []; : selectedSet?.capabilities || [];
const capabilitySetLabel = selectedSet?.label
|| selectedSet?.slug
|| token.capability_set_id
|| '—';
return ( return (
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}> <tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
<td>{token.label || '—'}</td> <td>{token.label || '—'}</td>
@@ -340,36 +360,17 @@ const SettingsModal = ({
<td>{formatDateTime(token.last_used_at)}</td> <td>{formatDateTime(token.last_used_at)}</td>
<td>{formatDateTime(token.expires_at)}</td> <td>{formatDateTime(token.expires_at)}</td>
<td> <td>
<div className="settings-form__choices"> <div className="settings-capability-set">
{capabilityOptions.map((option) => { <span className="settings-capability-set__label">{capabilitySetLabel}</span>
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> </div>
{capabilitySetsLoading ? (
<small>Loading capability sets</small>
) : null}
{!capabilitySetsLoading && capabilityList.length ? (
<div className="settings-capabilities">
<small>Includes: {capabilityList.join(', ')}</small>
</div>
) : null}
</td> </td>
<td className="settings-table__actions"> <td className="settings-table__actions">
{isRevoked ? ( {isRevoked ? (
@@ -414,12 +415,13 @@ const SettingsModal = ({
creating, creating,
deletingId, deletingId,
regeneratingId, regeneratingId,
updatingId,
newTokenLabel, newTokenLabel,
newTokenExpires, newTokenExpires,
newTokenCapabilities, newTokenCapabilitySetId,
formError, formError,
capabilityOptions, capabilitySetOptions,
capabilitySetMap,
capabilitySetsLoading,
formatDateTime, formatDateTime,
handleCopyToken, handleCopyToken,
handleCreateToken, handleCreateToken,
@@ -427,8 +429,7 @@ const SettingsModal = ({
handleRefresh, handleRefresh,
onDelete, onDelete,
handleDismissSecret, handleDismissSecret,
handleNewCapabilityChange, handleNewCapabilitySetChange,
handleToggleTokenCapability,
]); ]);
const renderPasskeysSection = useMemo(() => { const renderPasskeysSection = useMemo(() => {
+32 -45
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
const [tokens, setTokens] = useState([]); const [tokens, setTokens] = useState([]);
@@ -6,8 +6,25 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState(null); const [deletingId, setDeletingId] = useState(null);
const [regeneratingId, setRegeneratingId] = useState(null); const [regeneratingId, setRegeneratingId] = useState(null);
const [updatingId, setUpdatingId] = 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) {
@@ -25,10 +42,13 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
}, [api, notifyApiError, token]); }, [api, notifyApiError, token]);
const create = useCallback( const create = useCallback(
async ({ label, expires_at, capabilities } = {}) => { async ({ label, expires_at, capability_set_id } = {}) => {
if (creating) { if (creating) {
return false; return false;
} }
if (!capability_set_id) {
return false;
}
setCreating(true); setCreating(true);
try { try {
const payload = {}; const payload = {};
@@ -38,9 +58,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
if (expires_at) { if (expires_at) {
payload.expires_at = expires_at; payload.expires_at = expires_at;
} }
if (Array.isArray(capabilities) && capabilities.length > 0) { payload.capability_set_id = capability_set_id;
payload.capabilities = capabilities;
}
const { data } = await api.post('/profile/api-tokens', payload); const { data } = await api.post('/profile/api-tokens', payload);
if (data?.token_info) { if (data?.token_info) {
@@ -132,45 +150,13 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
[api, notifyApiError, refresh, setStatusMessage], [api, notifyApiError, refresh, setStatusMessage],
); );
const updateCapabilities = useCallback( useEffect(() => {
async (tokenId, capabilities) => { if (token) {
if (!tokenId) { refreshCapabilitySets();
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 { } else {
await refresh(); setCapabilitySets([]);
} }
setStatusMessage?.('API token updated.', 'success'); }, [token, refreshCapabilitySets]);
return true;
} catch (error) {
notifyApiError?.(error, 'Failed to update API token.');
return false;
} finally {
setUpdatingId(null);
}
},
[api, notifyApiError, refresh, setStatusMessage],
);
const dismissSecret = useCallback(() => { const dismissSecret = useCallback(() => {
setCreatedSecret(null); setCreatedSecret(null);
@@ -182,14 +168,15 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
creating, creating,
deletingId, deletingId,
regeneratingId, regeneratingId,
updatingId,
createdSecret, createdSecret,
refresh, refresh,
create, create,
revoke, revoke,
regenerate, regenerate,
updateCapabilities,
dismissSecret, dismissSecret,
capabilitySets,
capabilitySetsLoading,
refreshCapabilitySets,
}; };
}; };