Files
PiCloud/dashboard/src/routes/users/+page.svelte
MechaCat02 c4fa53052d feat(dashboard): confirm modal for user deactivate
Deactivation signs the user out and expires every API key they hold —
warrants a styled confirm. Reactivation stays one-click since it's
non-destructive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:36:17 +02:00

987 lines
24 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
/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 ActionMenu from '$lib/ActionMenu.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;
}>({ username: '', email: '', instance_role: 'admin' });
let editPending = $state(false);
let editError = $state<string | null>(null);
// Delete modal -----------------------------------------------------------
let deleteTarget = $state<AdminDto | null>(null);
let deletePending = $state(false);
// Deactivate modal -------------------------------------------------------
// Reactivate is one-click (non-destructive); deactivate routes
// through the modal because it signs the user out and expires
// every API key they hold.
let deactivateTarget = $state<AdminDto | null>(null);
let deactivatePending = $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
};
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;
} = {};
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;
}
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 reactivate(row: AdminDto) {
try {
const updated = await api.admins.update(row.id, { is_active: true });
admins = admins.map((a) => (a.id === updated.id ? updated : a));
flash('info', `${updated.username} reactivated.`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
}
}
function askDeactivate(row: AdminDto) {
deactivateTarget = row;
}
async function confirmDeactivate() {
if (!deactivateTarget) return;
deactivatePending = true;
const target = deactivateTarget;
try {
const updated = await api.admins.update(target.id, { is_active: false });
admins = admins.map((a) => (a.id === updated.id ? updated : a));
deactivateTarget = null;
flash('info', `${updated.username} deactivated.`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
} finally {
deactivatePending = false;
}
}
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">
<ActionMenu
label="User actions for {row.username}"
items={[
{ label: 'Edit', onClick: () => openEdit(row) },
{
label: row.is_active ? 'Deactivate' : 'Reactivate',
onClick: () =>
row.is_active ? askDeactivate(row) : reactivate(row)
},
{
label: 'Delete',
danger: true,
disabled: !canDelete(row),
onClick: () => openDelete(row)
}
]}
/>
</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>
{#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}
<!-- Deactivate confirmation -->
{#if deactivateTarget}
{@const dt = deactivateTarget}
<ConfirmModal
title="Deactivate {dt.username}?"
variant="danger"
confirmLabel="Deactivate"
busyLabel="Deactivating…"
busy={deactivatePending}
onConfirm={confirmDeactivate}
onCancel={() => (deactivateTarget = null)}
>
<p>
Deactivating signs <strong>{dt.username}</strong> out immediately and
expires <strong>every API key</strong> they hold. Their sessions and keys
won't come back if you reactivate — they'll need to log in again and
mint new keys.
</p>
<p class="muted">
Reactivation is one click — this isn't permanent.
</p>
</ConfirmModal>
{/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 2.5rem;
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;
}
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;
}
.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>