capsapi
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user