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>
987 lines
24 KiB
Svelte
987 lines
24 KiB
Svelte
<!--
|
||
/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>2–32 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">2–32 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>
|