feat(dashboard): users admin page with invite/edit/delete + password reveal

/admin/users is the owner+admin surface for managing the platform's
user list. Members get bounced to /profile?denied=users.

Invite generates a random 16-char password client-side, POSTs the
new user, and surfaces the cleartext exactly once in a yellow-
bordered reveal modal with a Copy button and an "I've shared it"
acknowledgement gate. Owner role is intentionally not in the create
form — promote via Edit after creation, matching the backend's
deliberate-step comment.

Edit handles username, email, role (with affordance hiding: admins
see admin/member only), is_active toggle, and a separate "Reset
password" button that re-uses the same reveal flow. Delete uses
ConfirmModal with confirmPhrase=username and explains the
last-owner/last-admin 422s up front.

Username + email validated client-side against the same patterns
the backend enforces so the form fails fast rather than always
on the round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 08:05:02 +02:00
parent f16ff22a5a
commit 700ae7b7d1

View File

@@ -0,0 +1,980 @@
<!--
/admin/users — owner + admin only. Members get bounced to /profile
with ?denied=users. Replaces the pre-3.5 /admin/admins page; this
one knows about roles, email, and the last-owner/last-admin guards.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import {
api,
ApiError,
type AdminDto,
type InstanceRole
} from '$lib/api';
import { currentUser } from '$lib/auth';
import RoleChip from '$lib/RoleChip.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import { generatePassword } from '$lib/password-gen';
const me = $derived($currentUser);
const myRole = $derived(me?.instance_role);
const isOwner = $derived(myRole === 'owner');
// Member guard. The backend already 403s the list call, but
// surfacing a friendly redirect avoids the dead-end empty page.
$effect(() => {
if (me && me.instance_role === 'member') {
void goto(`${base}/profile?denied=users`);
}
});
let admins = $state<AdminDto[]>([]);
let loadError = $state<string | null>(null);
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
let search = $state('');
const filtered = $derived(
(() => {
const q = search.trim().toLowerCase();
if (!q) return admins;
return admins.filter(
(a) =>
a.username.toLowerCase().includes(q) ||
(a.email ?? '').toLowerCase().includes(q)
);
})()
);
// Invite (create) modal --------------------------------------------------
let inviteOpen = $state(false);
let inviteForm = $state<{ username: string; email: string; instance_role: 'admin' | 'member' }>({
username: '',
email: '',
instance_role: 'admin'
});
let invitePending = $state(false);
let inviteError = $state<string | null>(null);
// One-time password reveal (used by both invite + reset)
let revealPassword = $state<string | null>(null);
let revealForUsername = $state<string>('');
let revealKind = $state<'invite' | 'reset'>('invite');
let revealAck = $state(false);
let copyState = $state<'idle' | 'copied'>('idle');
// Edit modal -------------------------------------------------------------
let editTarget = $state<AdminDto | null>(null);
let editForm = $state<{
username: string;
email: string;
instance_role: InstanceRole;
is_active: boolean;
}>({ username: '', email: '', instance_role: 'admin', is_active: true });
let editPending = $state(false);
let editError = $state<string | null>(null);
// Delete modal -----------------------------------------------------------
let deleteTarget = $state<AdminDto | null>(null);
let deletePending = $state(false);
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const inviteUsernameValid = $derived(USERNAME_RE.test(inviteForm.username));
const inviteEmailValid = $derived(
inviteForm.email.trim() === '' || EMAIL_RE.test(inviteForm.email.trim())
);
const canInvite = $derived(inviteUsernameValid && inviteEmailValid && !invitePending);
const editUsernameValid = $derived(USERNAME_RE.test(editForm.username));
const editEmailValid = $derived(
editForm.email.trim() === '' || EMAIL_RE.test(editForm.email.trim())
);
const canSubmitEdit = $derived(editUsernameValid && editEmailValid && !editPending);
// Admin (non-owner) cannot touch owner rows for delete or role demote.
function canDelete(row: AdminDto): boolean {
if (isOwner) return true;
return row.instance_role !== 'owner';
}
const editRoleOptions = $derived<InstanceRole[]>(
isOwner ? ['owner', 'admin', 'member'] : ['admin', 'member']
);
onMount(refresh);
async function refresh() {
loadError = null;
try {
admins = await api.admins.list();
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'failed to load users';
}
}
function flash(kind: 'error' | 'info', message: string) {
banner = { kind, message };
setTimeout(() => {
if (banner?.message === message) banner = null;
}, 6000);
}
function openInvite() {
inviteForm = { username: '', email: '', instance_role: 'admin' };
inviteError = null;
inviteOpen = true;
}
async function submitInvite(event: SubmitEvent) {
event.preventDefault();
if (!canInvite) return;
invitePending = true;
inviteError = null;
const password = generatePassword(16);
try {
const created = await api.admins.create({
username: inviteForm.username,
password,
instance_role: inviteForm.instance_role,
email: inviteForm.email.trim() === '' ? null : inviteForm.email.trim()
});
admins = [...admins, created].sort((a, b) => a.username.localeCompare(b.username));
inviteOpen = false;
revealPassword = password;
revealForUsername = created.username;
revealKind = 'invite';
revealAck = false;
copyState = 'idle';
} catch (e) {
inviteError = e instanceof ApiError ? e.message : 'failed to create user';
} finally {
invitePending = false;
}
}
function openEdit(row: AdminDto) {
editTarget = row;
editForm = {
username: row.username,
email: row.email ?? '',
instance_role: row.instance_role,
is_active: row.is_active
};
editError = null;
}
async function submitEdit(event: SubmitEvent) {
event.preventDefault();
if (!editTarget || !canSubmitEdit) return;
editPending = true;
editError = null;
const patch: {
username?: string;
email?: string | null;
instance_role?: InstanceRole;
is_active?: boolean;
} = {};
if (editForm.username !== editTarget.username) patch.username = editForm.username;
if ((editTarget.email ?? '') !== editForm.email.trim()) {
patch.email = editForm.email.trim() === '' ? null : editForm.email.trim();
}
if (editForm.instance_role !== editTarget.instance_role) {
patch.instance_role = editForm.instance_role;
}
if (editForm.is_active !== editTarget.is_active) patch.is_active = editForm.is_active;
try {
const updated = await api.admins.update(editTarget.id, patch);
admins = admins
.map((a) => (a.id === updated.id ? updated : a))
.sort((a, b) => a.username.localeCompare(b.username));
const name = updated.username;
editTarget = null;
flash('info', `Updated "${name}".`);
} catch (e) {
editError = e instanceof ApiError ? e.message : 'failed to update user';
} finally {
editPending = false;
}
}
async function resetPassword() {
if (!editTarget) return;
const target = editTarget;
const password = generatePassword(16);
editPending = true;
editError = null;
try {
await api.admins.update(target.id, { password });
editTarget = null;
revealPassword = password;
revealForUsername = target.username;
revealKind = 'reset';
revealAck = false;
copyState = 'idle';
} catch (e) {
editError = e instanceof ApiError ? e.message : 'failed to reset password';
} finally {
editPending = false;
}
}
async function toggleActive(row: AdminDto) {
try {
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
admins = admins.map((a) => (a.id === updated.id ? updated : a));
flash(
'info',
`${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`
);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
}
}
function openDelete(row: AdminDto) {
deleteTarget = row;
}
async function confirmDelete() {
if (!deleteTarget) return;
deletePending = true;
const target = deleteTarget;
try {
await api.admins.remove(target.id);
deleteTarget = null;
if (me && me.id === target.id) {
// Self-delete: bail out to login.
await api.auth.logout();
await goto(`${base}/login`);
return;
}
admins = admins.filter((a) => a.id !== target.id);
flash('info', `Deleted "${target.username}".`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to delete user');
} finally {
deletePending = false;
}
}
async function copyPassword() {
if (!revealPassword) return;
try {
await navigator.clipboard.writeText(revealPassword);
copyState = 'copied';
setTimeout(() => (copyState = 'idle'), 2000);
} catch {
flash('error', 'Clipboard write failed — select and copy manually.');
}
}
function dismissReveal() {
revealPassword = null;
revealAck = false;
}
function relative(iso: string | null): string {
if (!iso) return 'Never';
const sec = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.round(hr / 24);
if (day < 7) return `${day}d ago`;
return new Date(iso).toISOString().slice(0, 10);
}
function shortDate(iso: string): string {
return new Date(iso).toISOString().slice(0, 10);
}
</script>
<header class="head">
<h1>Users</h1>
<div class="head-controls">
<input
type="search"
placeholder="Search by username or email…"
bind:value={search}
class="search"
/>
<button type="button" class="primary" onclick={openInvite}>+ Invite user</button>
</div>
</header>
{#if banner}
<div class="banner banner-{banner.kind}">{banner.message}</div>
{/if}
{#if loadError}
<div class="error">
{loadError}
<button type="button" class="retry" onclick={refresh}>Retry</button>
</div>
{:else if admins.length === 0}
<p class="empty">No users yet. Invite one to get started.</p>
{:else}
<div class="table">
<div class="row head-row">
<div>Username</div>
<div>Role</div>
<div>Email</div>
<div>Status</div>
<div>Created</div>
<div>Last login</div>
<div class="actions-col"></div>
</div>
{#each filtered as row (row.id)}
<div class="row">
<div class="name-cell">
<span class="name">{row.username}</span>
{#if me && me.id === row.id}
<span class="you-tag">(you)</span>
{/if}
</div>
<div><RoleChip role={row.instance_role} size="sm" /></div>
<div class="email-cell">{row.email ?? '—'}</div>
<div>
{#if row.is_active}
<span class="status status-active">● Active</span>
{:else}
<span class="status status-inactive">○ Inactive</span>
{/if}
</div>
<div>{shortDate(row.created_at)}</div>
<div title={row.last_login_at ?? ''}>{relative(row.last_login_at)}</div>
<div class="actions-col">
<button type="button" class="row-action" onclick={() => openEdit(row)}>Edit</button>
<button type="button" class="row-action" onclick={() => toggleActive(row)}>
{row.is_active ? 'Deactivate' : 'Reactivate'}
</button>
{#if canDelete(row)}
<button
type="button"
class="row-action danger-link"
onclick={() => openDelete(row)}
>
Delete
</button>
{/if}
</div>
</div>
{/each}
{#if filtered.length === 0 && admins.length > 0}
<div class="row empty-row">No matches for "{search}".</div>
{/if}
</div>
{/if}
<!-- Invite modal -->
{#if inviteOpen}
<div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget && !invitePending) inviteOpen = false;
}}
>
<form class="modal" onsubmit={submitInvite}>
<div class="modal-head">
<h2>Invite user</h2>
<button
type="button"
class="x"
aria-label="Close"
disabled={invitePending}
onclick={() => (inviteOpen = false)}></button
>
</div>
<p class="modal-intro">
A random password will be generated and shown to you exactly once. PiCloud cannot send
email — copy and share through your own channel.
</p>
<label class="field">
<span>Username</span>
<input
type="text"
autocomplete="off"
spellcheck="false"
bind:value={inviteForm.username}
required
/>
<small>232 chars. Lowercase letters, digits, <code>.</code> <code>_</code> <code>-</code>.</small>
{#if inviteForm.username && !inviteUsernameValid}
<small class="invalid">Doesn't match the allowed pattern.</small>
{/if}
</label>
<label class="field">
<span>Email <span class="opt">(optional)</span></span>
<input
type="email"
autocomplete="off"
spellcheck="false"
bind:value={inviteForm.email}
/>
{#if !inviteEmailValid}
<small class="invalid">Doesn't look like an email address.</small>
{/if}
</label>
<fieldset class="field">
<legend>Role</legend>
<label class="radio">
<input type="radio" bind:group={inviteForm.instance_role} value="admin" />
<span>Admin — can manage users, scripts, and all apps.</span>
</label>
<label class="radio">
<input type="radio" bind:group={inviteForm.instance_role} value="member" />
<span>Member — only sees apps they're added to.</span>
</label>
<small>
Owners can't be created here — promote via Edit after creation.
</small>
</fieldset>
{#if inviteError}
<div class="error">{inviteError}</div>
{/if}
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (inviteOpen = false)} disabled={invitePending}>
Cancel
</button>
<button type="submit" class="primary" disabled={!canInvite}>
{invitePending ? 'Creating…' : 'Create user'}
</button>
</div>
</form>
</div>
{/if}
<!-- Edit modal -->
{#if editTarget}
{@const target = editTarget}
<div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget && !editPending) editTarget = null;
}}
>
<form class="modal" onsubmit={submitEdit}>
<div class="modal-head">
<h2>Edit {target.username}</h2>
<button
type="button"
class="x"
aria-label="Close"
disabled={editPending}
onclick={() => (editTarget = null)}></button
>
</div>
<label class="field">
<span>Username</span>
<input
type="text"
autocomplete="off"
spellcheck="false"
bind:value={editForm.username}
required
/>
{#if editForm.username && !editUsernameValid}
<small class="invalid">232 chars, lowercase + digits + . _ - only.</small>
{/if}
</label>
<label class="field">
<span>Email <span class="opt">(optional)</span></span>
<input
type="email"
autocomplete="off"
spellcheck="false"
bind:value={editForm.email}
/>
{#if !editEmailValid}
<small class="invalid">Doesn't look like an email address.</small>
{/if}
</label>
<label class="field">
<span>Role</span>
<select bind:value={editForm.instance_role}>
{#each editRoleOptions as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
<small>
{#if target.instance_role === 'owner' && !isOwner}
Only owners can change another owner's role.
{:else if !isOwner}
Admins can grant admin or member; only owners can grant owner.
{:else}
The last active owner can't be demoted — the request will 422 if that's the case.
{/if}
</small>
</label>
<label class="toggle">
<input type="checkbox" bind:checked={editForm.is_active} />
<span>Active</span>
<small>Unchecking signs the user out and expires all their API keys immediately.</small>
</label>
{#if editError}
<div class="error">{editError}</div>
{/if}
<div class="modal-actions split">
<button type="button" class="ghost" onclick={resetPassword} disabled={editPending}>
Reset password
</button>
<div class="modal-actions">
<button
type="button"
class="ghost"
onclick={() => (editTarget = null)}
disabled={editPending}
>
Cancel
</button>
<button type="submit" class="primary" disabled={!canSubmitEdit}>
{editPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</form>
</div>
{/if}
<!-- Password reveal (post-invite or post-reset) -->
{#if revealPassword}
<div class="modal-backdrop" role="presentation">
<div class="modal reveal-modal">
<div class="modal-head">
<h2>
{revealKind === 'invite' ? 'User created' : 'Password reset'}{revealForUsername}
</h2>
</div>
<p class="banner banner-warn">
Save this password now — it will never be shown again. PiCloud cannot send email yet,
so copy it and share through your own channel.
</p>
<div class="token-row">
<code class="token">{revealPassword}</code>
<button type="button" class="ghost" onclick={copyPassword}>
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
</button>
</div>
<label class="ack">
<input type="checkbox" bind:checked={revealAck} />
<span>I've shared this with the user.</span>
</label>
<div class="modal-actions">
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
Done
</button>
</div>
</div>
</div>
{/if}
<!-- Delete confirmation -->
{#if deleteTarget}
{@const dt = deleteTarget}
<ConfirmModal
title="Delete user?"
variant="danger"
confirmLabel="Delete user"
confirmPhrase={dt.username}
confirmPhrasePrompt="Type the username to confirm:"
busy={deletePending}
busyLabel="Deleting…"
onConfirm={confirmDelete}
onCancel={() => (deleteTarget = null)}
>
{#if me && me.id === dt.id}
<p>
You're about to delete <strong>your own</strong> account. You'll be signed out
immediately and won't be able to sign back in.
</p>
{:else}
<p>
This permanently removes <strong>{dt.username}</strong>, all their sessions, and all
their API keys. This cannot be undone.
</p>
{/if}
<p class="muted">
If they're the only remaining owner or active admin the server will reject the request
with a 422 — promote/activate someone else first.
</p>
</ConfirmModal>
{/if}
<style>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.head h1 {
font-size: 1.25rem;
margin: 0;
color: #e2e8f0;
}
.head-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.search {
background: #0b1220;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
color: #e2e8f0;
font-size: 0.85rem;
min-width: 16rem;
}
.search:focus {
outline: none;
border-color: #38bdf8;
}
.banner {
padding: 0.55rem 0.85rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.banner-error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
}
.banner-info {
background: #0c2a36;
border: 1px solid #155e75;
color: #a5f3fc;
}
.banner-warn {
background: #2a1d04;
border: 1px solid #ca8a04;
color: #fde68a;
margin: 0;
}
.empty {
color: #64748b;
text-align: center;
padding: 2.5rem 0;
border: 1px dashed #1e293b;
border-radius: 0.5rem;
}
.table {
display: flex;
flex-direction: column;
border: 1px solid #1e293b;
border-radius: 0.5rem;
background: #0b1220;
overflow: visible;
}
.row {
display: grid;
grid-template-columns: 1.3fr 0.7fr 1.5fr 0.9fr 0.8fr 0.9fr 1.6fr;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-bottom: 1px solid #1e293b;
font-size: 0.85rem;
}
.row:last-child {
border-bottom: none;
}
.head-row {
color: #94a3b8;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #0f172a;
}
.empty-row {
grid-column: 1 / -1;
color: #64748b;
text-align: center;
padding: 1.25rem;
}
.name-cell {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.name {
color: #e2e8f0;
font-weight: 500;
}
.you-tag {
color: #64748b;
font-size: 0.72rem;
}
.email-cell {
color: #cbd5e1;
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
font-size: 0.8rem;
}
.status-active {
color: #34d399;
}
.status-inactive {
color: #64748b;
}
.actions-col {
display: flex;
justify-content: flex-end;
gap: 0.25rem;
flex-wrap: wrap;
}
.row-action {
background: transparent;
color: #cbd5e1;
border: 1px solid #334155;
padding: 0.25rem 0.55rem;
border-radius: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
}
.row-action:hover {
background: #1e293b;
color: #e2e8f0;
}
.danger-link {
color: #fca5a5;
border-color: #7f1d1d;
}
.danger-link:hover {
background: #450a0a;
color: #fecaca;
}
button.primary {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 0.9rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
}
button.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.ghost {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
padding: 0.45rem 0.85rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.85rem;
}
button.ghost:hover {
background: #1e293b;
color: #e2e8f0;
}
.error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.55rem 0.8rem;
border-radius: 0.375rem;
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.retry {
background: transparent;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.2rem 0.55rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.75rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.7);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 50;
}
.modal {
background: #0b1220;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 1.5rem;
width: 100%;
max-width: 28rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
}
.reveal-modal {
border-color: #ca8a04;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal h2 {
margin: 0;
font-size: 1rem;
color: #e2e8f0;
}
.x {
background: transparent;
border: none;
color: #64748b;
font-size: 1.1rem;
cursor: pointer;
}
.x:hover {
color: #e2e8f0;
}
.modal-intro {
margin: 0;
font-size: 0.82rem;
color: #94a3b8;
line-height: 1.45;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
color: #cbd5e1;
border: none;
padding: 0;
margin: 0;
}
.field legend {
font-size: 0.85rem;
color: #cbd5e1;
padding: 0;
margin-bottom: 0.3rem;
}
.field input[type='text'],
.field input[type='email'],
.field select {
background: #0f172a;
color: #e2e8f0;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.5rem 0.7rem;
font-size: 0.9rem;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: #38bdf8;
}
.field small {
color: #64748b;
font-size: 0.72rem;
}
.field small.invalid {
color: #fca5a5;
}
.field small code {
background: #1e293b;
color: #cbd5e1;
padding: 0 0.2rem;
border-radius: 0.2rem;
}
.opt {
color: #64748b;
font-weight: 400;
}
.radio {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.82rem;
color: #cbd5e1;
}
.toggle {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.toggle :global(input[type='checkbox']) {
margin-right: 0.4rem;
}
.toggle small {
color: #64748b;
font-size: 0.72rem;
margin-left: 1.3rem;
}
.token-row {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.token {
flex: 1;
background: #020617;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.6rem 0.75rem;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
overflow-x: auto;
white-space: nowrap;
}
.ack {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #cbd5e1;
cursor: pointer;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-actions.split {
justify-content: space-between;
}
.muted {
color: #94a3b8;
}
</style>