This commit is contained in:
2025-10-29 00:40:13 +01:00
parent d9b2297eca
commit 4b18429945
6 changed files with 622 additions and 47 deletions
+2 -8
View File
@@ -54,7 +54,7 @@ use uuid::Uuid;
schemas(
schemas::LoginRequest,
schemas::AccessTokenResponse,
schemas::TenantSummary,
schemas::TenantSnippet,
schemas::TenantSelectionResponse,
schemas::TenantSelectionRequest,
schemas::TenantListResponse,
@@ -605,12 +605,6 @@ pub mod schemas {
pub tenant: TenantSnippet,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSnippet {
pub id: Uuid,
@@ -620,7 +614,7 @@ pub mod schemas {
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionResponse {
pub access_token: String,
pub tenants: Vec<TenantSummary>,
pub tenants: Vec<TenantSnippet>,
}
#[derive(Serialize, Deserialize, ToSchema)]
+3 -9
View File
@@ -46,12 +46,6 @@ pub struct LoginResponse {
pub tenant: TenantSnippet,
}
#[derive(Serialize)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSnippet {
pub id: Uuid,
@@ -61,7 +55,7 @@ pub struct TenantSnippet {
#[derive(Serialize)]
pub struct TenantSelectionResponse {
pub access_token: String,
pub tenants: Vec<TenantSummary>,
pub tenants: Vec<TenantSnippet>,
}
#[derive(Serialize)]
@@ -132,8 +126,8 @@ pub async fn login(
let tenants = memberships
.into_iter()
.map(|(_, tenant)| TenantSummary {
tenant_id: tenant.id,
.map(|(_, tenant)| TenantSnippet {
id: tenant.id,
slug: tenant.slug,
})
.collect();
+5 -5
View File
@@ -100,7 +100,7 @@
pointer-events: auto;
cursor: grab;
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.18);
box-shadow: 2px 2px 4px color-mix(in oklch, black 18%, transparent);
}
.tag-chip--draggable:active {
@@ -146,14 +146,14 @@
padding: 0.25em;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
background: oklch(0.22 0.06 260deg);
color: var(--on-accent);
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
.skeuo-card__nav-button:hover:not([disabled]) {
background: rgba(0, 0, 0, 0.75);
background: oklch(0.18 0.06 260deg);
}
.skeuo-card__nav-button:disabled {
@@ -192,7 +192,7 @@ body.skeuo-cursor-remove * {
justify-content: center;
width: 100%;
height: 100%;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
box-shadow: 0 12px 32px color-mix(in oklch, black 18%, transparent);
overflow: hidden;
}
+114 -1
View File
@@ -539,6 +539,11 @@ const AppLayout = () => {
const [searchResults, setSearchResults] = useState(null);
const [tags, setTags] = useState([]);
const [correspondents, setCorrespondents] = useState([]);
const [webdavTokens, setWebdavTokens] = useState([]);
const [webdavTokensLoading, setWebdavTokensLoading] = useState(false);
const [creatingWebdavToken, setCreatingWebdavToken] = useState(false);
const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null);
const [webdavTokenSecret, setWebdavTokenSecret] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeTagFilters, setActiveTagFilters] = useState([]);
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
@@ -743,6 +748,11 @@ const AppLayout = () => {
setSearchResults(null);
setTags([]);
setCorrespondents([]);
setWebdavTokens([]);
setWebdavTokensLoading(false);
setCreatingWebdavToken(false);
setDeletingWebdavTokenId(null);
setWebdavTokenSecret(null);
setSearchQuery('');
setActiveTagFilters([]);
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
@@ -1734,6 +1744,97 @@ const AppLayout = () => {
}
}, [api, notifyApiError]);
const refreshWebdavTokens = useCallback(async () => {
if (!token) {
return;
}
setWebdavTokensLoading(true);
try {
const { data } = await api.get('/profile/webdav-tokens');
setWebdavTokens(Array.isArray(data) ? data : []);
} catch (error) {
notifyApiError(error, 'Failed to load WebDAV tokens.');
} finally {
setWebdavTokensLoading(false);
}
}, [api, notifyApiError, token]);
const createWebdavToken = useCallback(
async ({ label, expires_at } = {}) => {
if (creatingWebdavToken) {
return false;
}
setCreatingWebdavToken(true);
try {
const payload = {};
if (label) {
payload.label = label;
}
if (expires_at) {
payload.expires_at = expires_at;
}
const { data } = await api.post('/profile/webdav-tokens', payload);
if (data?.token_info) {
setWebdavTokens((previous) => {
const filtered = previous.filter((entry) => entry.id !== data.token_info.id);
return [data.token_info, ...filtered];
});
} else {
await refreshWebdavTokens();
}
if (data?.token) {
setWebdavTokenSecret(data.token);
}
setStatusMessage('WebDAV token created.', 'success');
return data;
} catch (error) {
notifyApiError(error, 'Failed to create WebDAV token.');
return false;
} finally {
setCreatingWebdavToken(false);
}
},
[api, creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage],
);
const deleteWebdavToken = useCallback(
async (tokenId) => {
if (!tokenId) {
return false;
}
setDeletingWebdavTokenId(tokenId);
try {
await api.delete(`/profile/webdav-tokens/${tokenId}`);
setWebdavTokens((previous) => previous.filter((entry) => entry.id !== tokenId));
setStatusMessage('WebDAV token revoked.', 'success');
return true;
} catch (error) {
notifyApiError(error, 'Failed to revoke WebDAV token.');
return false;
} finally {
setDeletingWebdavTokenId(null);
}
},
[api, notifyApiError, setStatusMessage],
);
const dismissCreatedWebdavToken = useCallback(() => {
setWebdavTokenSecret(null);
}, []);
useEffect(() => {
if (!isSettingsModalOpen) {
return;
}
refreshWebdavTokens();
}, [isSettingsModalOpen, refreshWebdavTokens]);
useEffect(() => {
if (!isSettingsModalOpen) {
setWebdavTokenSecret(null);
}
}, [isSettingsModalOpen]);
const handleTagUpdate = useCallback(
async (tagId, changes) => {
if (!tagId) {
@@ -5404,7 +5505,19 @@ const AppLayout = () => {
</div>
</div>
)}
<SettingsModal open={isSettingsModalOpen} onClose={closeSettingsModal} />
<SettingsModal
open={isSettingsModalOpen}
onClose={closeSettingsModal}
tokens={webdavTokens}
loading={webdavTokensLoading}
creating={creatingWebdavToken}
deletingId={deletingWebdavTokenId}
onRefresh={refreshWebdavTokens}
onCreate={createWebdavToken}
onDelete={deleteWebdavToken}
createdToken={webdavTokenSecret}
onDismissCreatedToken={dismissCreatedWebdavToken}
/>
</div>
</AppShellContext.Provider>
);
+284
View File
@@ -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;
+214 -24
View File
@@ -36,6 +36,18 @@
--shadow-faint: color-mix(in oklch, black 6%, transparent);
--shadow-soft: color-mix(in oklch, black 12%, transparent);
--shadow-medium: color-mix(in oklch, black 18%, transparent);
--shadow-strong: color-mix(in oklch, black 28%, transparent);
--shadow-deep: color-mix(in oklch, black 55%, transparent);
--shadow-pop: color-mix(in oklch, black 25%, transparent);
--outline-subtle: color-mix(in oklch, black 6%, transparent);
--overlay-dark: color-mix(in oklch, black 55%, transparent);
--overlay-dim: color-mix(in oklch, oklch(0.28 0.01 calc(265deg + var(--warmth))) 20%, transparent);
--overlay-darker: color-mix(in oklch, black 75%, transparent);
--surface-danger-subtle: color-mix(in oklch, var(--danger) 12%, transparent);
--overlay-accent-subtle: color-mix(in oklch, var(--accent) 12%, transparent);
--border-strong: color-mix(in oklch, var(--fg) 24%, transparent);
--surface-hover: color-mix(in oklch, white 10%, transparent);
--overlay-accent-strong: color-mix(in oklch, var(--accent) 24%, transparent);
--overlay-backdrop: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 45%, transparent);
--overlay-shadow: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 18%, transparent);
@@ -57,9 +69,17 @@
--sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent);
--selection-ring: oklch(0.78 0.16 calc(260deg + var(--warmth)));
--sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent);
--link: var(--accent);
--link-visited: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
--preview-nav-bg: oklch(0.18 0.05 calc(270deg + var(--warmth)));
--preview-nav-bg-hover: oklch(0.14 0.05 calc(270deg + var(--warmth)));
--preview-nav-fg: var(--on-accent);
--text-on-dark: color-mix(in oklch, white 90%, transparent);
--surface-ink-soft: color-mix(in oklch, var(--fg) 8%, transparent);
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 15px;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--detail-panel-width: 30em;
--documents-grid-title-size: 0.8rem;
@@ -86,7 +106,7 @@ a {
}
a:visited {
color: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
color: var(--link-visited);
}
a:hover,
@@ -244,7 +264,7 @@ button.danger:hover:not([disabled]) {
.preview-zoom-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0);
background: var(--overlay-backdrop);
transition: background 0.25s ease, opacity 0.25s ease;
display: flex;
align-items: center;
@@ -283,7 +303,7 @@ button.danger:hover:not([disabled]) {
justify-content: center;
max-width: 95vw;
max-height: 95vh;
box-shadow: 0 32px 120px rgba(15, 23, 42, 0.55);
box-shadow: 0 32px 120px var(--shadow-deep);
}
.preview-zoom__scroll:focus {
@@ -322,23 +342,23 @@ button.danger:hover:not([disabled]) {
height: 2.8rem;
border: none;
border-radius: 999px;
background: rgba(0, 0, 0, 0.62);
color: #fff;
background: var(--preview-nav-bg);
color: var(--preview-nav-fg);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1;
transition: background 0.15s ease, transform 0.15s ease;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.28);
box-shadow: 0 12px 28px var(--shadow-strong);
}
.preview-zoom__nav-button:hover {
background: rgba(0, 0, 0, 0.82);
background: var(--preview-nav-bg-hover);
}
.preview-zoom__nav-button:focus-visible {
outline: 2px solid var(--accent, #4f46e5);
outline: 2px solid var(--accent);
outline-offset: 3px;
}
@@ -604,9 +624,9 @@ button.danger:hover:not([disabled]) {
width: 64px;
height: 64px;
border-radius: 6px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
box-shadow: 0 6px 12px var(--shadow-pop);
overflow: hidden;
background-color: rgba(32, 34, 40, 0.2);
background-color: var(--overlay-dim);
display: flex;
align-items: center;
justify-content: center;
@@ -668,7 +688,7 @@ button.danger:hover:not([disabled]) {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.9);
color: var(--text-on-dark);
}
.document-drag-preview__count {
@@ -676,12 +696,12 @@ button.danger:hover:not([disabled]) {
bottom: 4px;
right: 4px;
background-color: var(--accent);
color: #fff;
color: var(--on-accent);
border-radius: 999px;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25);
box-shadow: 0 4px 8px var(--shadow-pop);
pointer-events: none;
}
@@ -703,7 +723,7 @@ button.danger:hover:not([disabled]) {
.preview-workspace__info {
background: var(--surface-subtle);
padding: 1rem;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
box-shadow: inset 0 0 0 1px var(--outline-subtle);
}
.preview-workspace__summary {
@@ -1152,7 +1172,7 @@ button.danger:hover:not([disabled]) {
}
.sidebar__title-button:focus-visible {
outline: 2px solid var(--accent, #2563eb);
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -1215,12 +1235,12 @@ button.danger:hover:not([disabled]) {
.menu button.menu__item.active,
.menu .menu__item.active {
font-weight: 600;
color: var(--accent, #2563eb);
color: var(--accent);
}
.menu button.menu__item:focus-visible,
.menu .menu__item:focus-visible {
outline: 2px solid var(--accent, #2563eb);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
@@ -1235,7 +1255,7 @@ button.danger:hover:not([disabled]) {
display: flex;
align-items: center;
justify-content: center;
color: var(--accent, #2563eb);
color: var(--accent);
}
.menu__active-indicator {
@@ -1888,7 +1908,7 @@ button.danger:hover:not([disabled]) {
border: none;
padding: 0;
margin: 0;
color: var(--accent, #2563eb);
color: var(--accent);
text-decoration: none;
font: inherit;
cursor: pointer;
@@ -1939,7 +1959,7 @@ button.danger:hover:not([disabled]) {
}
.documents-panel tbody tr.document.tag-drop-target {
background: var(--accent-soft, rgba(63, 106, 216, 0.12));
background: var(--accent-soft);
box-shadow: inset 0 0 0 2px var(--accent);
}
@@ -2113,7 +2133,7 @@ button.danger:hover:not([disabled]) {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace);
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.4;
}
@@ -2354,7 +2374,7 @@ button.danger:hover:not([disabled]) {
}
.preview-pane__nav-button:focus-visible {
outline: 2px solid var(--accent, #2684ff);
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -2426,7 +2446,7 @@ button.danger:hover:not([disabled]) {
border: 1px solid var(--border-muted, var(--border));
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace);
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.45;
}
@@ -2531,7 +2551,8 @@ form.inline {
}
.modal--panel {
width: min(80vw, 960px);
width: 80vw;
max-width: 960px;
max-height: 90vh;
padding: 0;
border-radius: 12px;
@@ -2539,6 +2560,175 @@ form.inline {
gap: 0;
}
.settings-modal__body {
display: grid;
grid-template-columns: 12rem 1fr;
gap: 1.5rem;
height: 90vh;
padding: 1.5rem;
}
.settings-modal__sidebar {
border-right: 1px solid var(--border-muted, var(--border));
padding-right: 1rem;
}
.settings-modal__sidebar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.settings-modal__sidebar button {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 0.4rem;
border: none;
background: none;
color: inherit;
font: inherit;
padding: 0.45rem 0.6rem;
border-radius: 0.35rem;
cursor: pointer;
}
.settings-modal__sidebar button:hover,
.settings-modal__sidebar button:focus-visible {
background: var(--sidebar-hover-bg);
}
.settings-modal__sidebar button.active {
background: var(--surface-soft, rgba(148, 163, 184, 0.18));
font-weight: 600;
}
.settings-modal__content {
overflow-y: auto;
padding-bottom: 1rem;
}
.settings-section h4 {
margin-top: 0;
}
.settings-section {
display: flex;
flex-direction: column;
}
.settings-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.settings-actions .secondary {
min-width: 7rem;
}
.settings-form {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
margin-bottom: 0.75rem;
}
.settings-form__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 14rem;
}
.settings-form__field input[type='text'],
.settings-form__field input[type='datetime-local'] {
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 0.35rem;
background: var(--surface-soft, rgba(148, 163, 184, 0.12));
color: inherit;
}
.settings-form__actions {
display: flex;
gap: 0.5rem;
}
.settings-form__error {
color: var(--danger, #d14343);
font-size: 0.85rem;
margin: 0 0 0.75rem;
}
.settings-empty {
margin: 1rem 0;
color: var(--muted);
}
.settings-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
}
.settings-table th,
.settings-table td {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--border-muted, var(--border));
text-align: left;
font-size: 0.9rem;
}
.settings-table tr:last-child td {
border-bottom: none;
}
.settings-table__actions {
white-space: nowrap;
}
.settings-status {
color: var(--muted);
font-size: 0.9rem;
}
.settings-table tr.is-revoked {
opacity: 0.65;
}
.settings-notice {
border: 1px solid var(--accent);
background: rgba(37, 99, 235, 0.08);
padding: 0.9rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.token-display {
background: rgba(15, 23, 42, 0.08);
padding: 0.5rem 0.65rem;
border-radius: 0.35rem;
font-family: var(--font-mono);
font-size: 0.95rem;
overflow-x: auto;
}
.settings-notice__actions {
display: flex;
gap: 0.5rem;
margin-top: 0.6rem;
}
.settings-notice__actions button {
flex: 0 0 auto;
}
.panel-modal__header {
display: flex;
align-items: center;