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
+118 -117
View File
@@ -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'}
</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.
API tokens use predefined capability sets. Choose the set that matches the access you
need when creating or updating a token.
</p>
{createdToken ? (
@@ -279,28 +271,49 @@ const SettingsModal = ({
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__field">
<label htmlFor="api-token-capability-set">Capability set</label>
<select
id="api-token-capability-set"
value={newTokenCapabilitySetId}
onChange={handleNewCapabilitySetChange}
disabled={
creating
|| capabilitySetsLoading
|| !capabilitySetOptions.length
}
>
{!capabilitySetOptions.length ? (
<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 className="settings-form__actions">
<button type="submit" disabled={creating}>
<button
type="submit"
disabled={
creating
|| capabilitySetsLoading
|| !capabilitySetOptions.length
}
>
{creating ? 'Creating…' : 'Create token'}
</button>
</div>
@@ -323,16 +336,23 @@ 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">Capability set</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
<td>{token.label || '—'}</td>
@@ -340,36 +360,17 @@ const SettingsModal = ({
<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 className="settings-capability-set">
<span className="settings-capability-set__label">{capabilitySetLabel}</span>
</div>
{capabilitySetsLoading ? (
<small>Loading capability sets</small>
) : null}
{!capabilitySetsLoading && capabilityList.length ? (
<div className="settings-capabilities">
<small>Includes: {capabilityList.join(', ')}</small>
</div>
) : null}
</td>
<td className="settings-table__actions">
{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(() => {
+34 -47
View File
@@ -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,
};
};