This commit is contained in:
2025-11-01 22:49:50 +01:00
parent 326a6042ec
commit 16ac16cd30
6 changed files with 962 additions and 750 deletions
+140 -12
View File
@@ -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>