work
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: 'webdav',
|
||||
label: 'WebDAV',
|
||||
},
|
||||
];
|
||||
|
||||
const SettingsModal = ({
|
||||
open,
|
||||
onClose,
|
||||
tokens = [],
|
||||
loading = false,
|
||||
creating = false,
|
||||
deletingId = null,
|
||||
onRefresh,
|
||||
onCreate,
|
||||
onDelete,
|
||||
createdToken = null,
|
||||
onDismissCreatedToken,
|
||||
}) => {
|
||||
const defaultSection = SECTIONS[0]?.id || 'webdav';
|
||||
const [activeSection, setActiveSection] = useState(defaultSection);
|
||||
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||
const [formError, setFormError] = useState(null);
|
||||
|
||||
const handleBackdropClick = useCallback(() => {
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
const handleInnerClick = useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveSection(defaultSection);
|
||||
setNewTokenLabel('');
|
||||
setNewTokenExpires('');
|
||||
setFormError(null);
|
||||
}
|
||||
}, [open, defaultSection]);
|
||||
|
||||
const formatDateTime = useCallback((value) => {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
const timestamp = new Date(value);
|
||||
if (Number.isNaN(timestamp.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return timestamp.toLocaleString();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
onRefresh?.();
|
||||
}, [onRefresh]);
|
||||
|
||||
const handleCopyToken = useCallback(() => {
|
||||
if (!createdToken) {
|
||||
return;
|
||||
}
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(createdToken).catch(() => {});
|
||||
}
|
||||
}, [createdToken]);
|
||||
|
||||
const handleDismissSecret = useCallback(() => {
|
||||
onDismissCreatedToken?.();
|
||||
}, [onDismissCreatedToken]);
|
||||
|
||||
const handleCreateToken = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
let normalizedLabel = newTokenLabel.trim();
|
||||
if (normalizedLabel.length === 0) {
|
||||
normalizedLabel = undefined;
|
||||
}
|
||||
|
||||
let normalizedExpires;
|
||||
if (newTokenExpires) {
|
||||
const parsed = new Date(newTokenExpires);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
setFormError('Enter a valid expiration date.');
|
||||
return;
|
||||
}
|
||||
normalizedExpires = parsed.toISOString();
|
||||
}
|
||||
|
||||
const result = await onCreate?.({
|
||||
label: normalizedLabel,
|
||||
expires_at: normalizedExpires,
|
||||
});
|
||||
|
||||
if (result !== false) {
|
||||
setNewTokenLabel('');
|
||||
setNewTokenExpires('');
|
||||
setFormError(null);
|
||||
}
|
||||
},
|
||||
[newTokenExpires, newTokenLabel, onCreate],
|
||||
);
|
||||
|
||||
const renderWebdavSection = useMemo(() => {
|
||||
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createdToken ? (
|
||||
<div className="settings-notice">
|
||||
<p>
|
||||
Copy this token now; you will not be able to view it again after closing this window.
|
||||
</p>
|
||||
<pre className="token-display">{createdToken}</pre>
|
||||
<div className="settings-notice__actions">
|
||||
<button type="button" className="secondary" onClick={handleCopyToken}>
|
||||
Copy token
|
||||
</button>
|
||||
<button type="button" onClick={handleDismissSecret}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="settings-form" onSubmit={handleCreateToken}>
|
||||
<div className="settings-form__field">
|
||||
<label htmlFor="webdav-token-label">Label</label>
|
||||
<input
|
||||
id="webdav-token-label"
|
||||
type="text"
|
||||
value={newTokenLabel}
|
||||
onChange={(event) => setNewTokenLabel(event.target.value)}
|
||||
placeholder="Personal WebDAV token"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-form__field">
|
||||
<label htmlFor="webdav-token-expires">Expires at</label>
|
||||
<input
|
||||
id="webdav-token-expires"
|
||||
type="datetime-local"
|
||||
value={newTokenExpires}
|
||||
onChange={(event) => setNewTokenExpires(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-form__actions">
|
||||
<button type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create token'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{formError ? <p className="settings-form__error">{formError}</p> : null}
|
||||
|
||||
{loading && !hasTokens ? (
|
||||
<p className="settings-empty">Loading tokens…</p>
|
||||
) : null}
|
||||
|
||||
{!loading && !hasTokens ? (
|
||||
<p className="settings-empty">No WebDAV tokens yet.</p>
|
||||
) : null}
|
||||
|
||||
{hasTokens ? (
|
||||
<table className="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Label</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Last used</th>
|
||||
<th scope="col">Expires</th>
|
||||
<th scope="col">Scopes</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => {
|
||||
const isRevoked = Boolean(token?.revoked_at);
|
||||
const scopes = Array.isArray(token?.scopes)
|
||||
? token.scopes.join(', ')
|
||||
: '—';
|
||||
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>{scopes || '—'}</td>
|
||||
<td className="settings-table__actions">
|
||||
{isRevoked ? (
|
||||
<span className="settings-status">Revoked</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => onDelete?.(token.id)}
|
||||
disabled={deletingId === token.id}
|
||||
>
|
||||
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
tokens,
|
||||
loading,
|
||||
createdToken,
|
||||
creating,
|
||||
deletingId,
|
||||
newTokenLabel,
|
||||
newTokenExpires,
|
||||
formError,
|
||||
formatDateTime,
|
||||
handleCopyToken,
|
||||
handleCreateToken,
|
||||
handleRefresh,
|
||||
onDelete,
|
||||
handleDismissSecret,
|
||||
]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onClick={handleBackdropClick}>
|
||||
<div
|
||||
className="modal modal--panel settings-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-modal-title"
|
||||
onClick={handleInnerClick}
|
||||
>
|
||||
<div className="panel-modal__header">
|
||||
<h3 id="settings-modal-title">Settings</h3>
|
||||
<button type="button" className="secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-modal__body">
|
||||
<nav className="settings-modal__sidebar" aria-label="Settings sections">
|
||||
<ul>
|
||||
{SECTIONS.map((section) => (
|
||||
<li key={section.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={section.id === activeSection ? 'active' : ''}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="settings-modal__content">
|
||||
{activeSection === 'webdav' ? renderWebdavSection : <p>Select a settings section.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
Reference in New Issue
Block a user