ui
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user