Files
PiCloud/dashboard/src/routes/apps/[slug]/+page.svelte
MechaCat02 d9c3d4d661 feat(dashboard): shadow apps + app-detail surfaces by role
Apps list: hide "New app" for members. App detail: hide New script for
viewers, Add domain + per-row Delete for non-admins, and the Members +
Settings tabs entirely for non-admins (with an effect that bounces a
stale activeTab back to Scripts).

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

1104 lines
27 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.
<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
api,
ApiError,
type AdminDto,
type App,
type AppDomain,
type AppMemberDto,
type AppRole,
type Script
} from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
const me = $derived($currentUser);
const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
type Tab = 'scripts' | 'domains' | 'members' | 'settings';
let slug = $derived(page.params.slug ?? '');
let app = $state<App | null>(null);
let myRole = $state<AppRole | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(true);
let activeTab = $state<Tab>('scripts');
let scripts = $state<Script[]>([]);
let domains = $state<AppDomain[]>([]);
let members = $state<AppMemberDto[]>([]);
// Derive UI gates from the capabilities helper so the rules stay
// in lockstep with the backend's `can()`. canAdminApp also covers
// the Members + Settings + Domains-mutation tabs; canWriteApp
// covers New script.
const canWrite = $derived(canWriteApp(me, myRole));
const canAdmin = $derived(canAdminApp(me, myRole));
// Script create
let showCreateScript = $state(false);
let createScriptName = $state('');
let createScriptDescription = $state('');
let createScriptSource = $state(SAMPLE_SOURCE);
let creatingScript = $state(false);
let createScriptError = $state<string | null>(null);
// Domain create
let createDomainPattern = $state('');
let creatingDomain = $state(false);
let createDomainError = $state<string | null>(null);
// Settings
let editName = $state('');
let editDescription = $state('');
let editSlug = $state('');
let savingSettings = $state(false);
let settingsError = $state<string | null>(null);
let slugTakeoverNeeded = $state<App | null>(null);
// Delete confirmations
let confirmingDeleteApp = $state(false);
let deletingApp = $state(false);
let deleteAppError = $state<string | null>(null);
let domainToRemove = $state<AppDomain | null>(null);
let removingDomain = $state(false);
let removeDomainError = $state<string | null>(null);
// Members tab
let eligibleUsers = $state<AdminDto[]>([]);
let eligibleLoadError = $state<string | null>(null);
let addMemberUserId = $state('');
let addMemberRole = $state<AppRole>('viewer');
let addingMember = $state(false);
let addMemberError = $state<string | null>(null);
let memberToRemove = $state<AppMemberDto | null>(null);
let removingMember = $state(false);
let removeMemberError = $state<string | null>(null);
let roleChangeBusy = $state<string | null>(null);
let memberActionError = $state<string | null>(null);
async function loadApp() {
loading = true;
loadError = null;
try {
const fetched = await api.apps.get(slug);
if (fetched.redirect_to && fetched.redirect_to !== slug) {
await goto(`${base}/apps/${fetched.redirect_to}`, { replaceState: true });
return;
}
app = {
id: fetched.id,
slug: fetched.slug,
name: fetched.name,
description: fetched.description,
created_at: fetched.created_at,
updated_at: fetched.updated_at
};
myRole = fetched.my_role;
editName = app.name;
editDescription = app.description ?? '';
editSlug = app.slug;
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers());
}
await Promise.all(loaders);
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function loadScripts(appId: string) {
try {
scripts = await api.scripts.list({ app: appId });
} catch (e) {
scripts = [];
loadError = e instanceof Error ? e.message : String(e);
}
}
async function loadDomains(appId: string) {
try {
domains = await api.domains.listForApp(appId);
} catch (e) {
domains = [];
loadError = e instanceof Error ? e.message : String(e);
}
}
async function loadMembers(appId: string) {
try {
members = await api.appMembers.list(appId);
} catch (e) {
members = [];
memberActionError = e instanceof Error ? e.message : String(e);
}
}
async function loadEligibleUsers() {
eligibleLoadError = null;
try {
const all = await api.admins.list();
// Only inactive=false members are valid invite targets — the
// API rejects everyone else anyway, so filter upfront.
eligibleUsers = all.filter(
(u) => u.is_active && u.instance_role === 'member'
);
} catch (e) {
eligibleUsers = [];
// member-with-app_admin can hit /apps/.../members but cannot
// browse /admins (gated on InstanceManageUsers). The add form
// will render disabled with the explanatory message below.
eligibleLoadError =
e instanceof ApiError && e.status === 403
? 'Only instance owners/admins can browse the user directory to invite new members.'
: e instanceof Error
? e.message
: String(e);
}
}
const eligibleAfterFilter = $derived(
eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
);
async function submitCreateScript(event: Event) {
event.preventDefault();
if (!app) return;
creatingScript = true;
createScriptError = null;
try {
await api.scripts.create({
app_id: app.id,
name: createScriptName.trim(),
description: createScriptDescription.trim() || null,
source: createScriptSource
});
showCreateScript = false;
createScriptName = '';
createScriptDescription = '';
createScriptSource = SAMPLE_SOURCE;
await loadScripts(app.id);
} catch (e) {
createScriptError = e instanceof Error ? e.message : String(e);
if (e instanceof ApiError && e.status === 422) {
createScriptError = `Validation: ${createScriptError}`;
}
} finally {
creatingScript = false;
}
}
async function submitCreateDomain(event: Event) {
event.preventDefault();
if (!app) return;
creatingDomain = true;
createDomainError = null;
try {
await api.domains.create(app.id, createDomainPattern.trim());
createDomainPattern = '';
await loadDomains(app.id);
} catch (e) {
createDomainError = e instanceof Error ? e.message : String(e);
} finally {
creatingDomain = false;
}
}
function askRemoveDomain(d: AppDomain) {
removeDomainError = null;
domainToRemove = d;
}
async function confirmRemoveDomain() {
if (!app || !domainToRemove) return;
removingDomain = true;
removeDomainError = null;
try {
await api.domains.remove(app.id, domainToRemove.id);
domainToRemove = null;
await loadDomains(app.id);
} catch (e) {
removeDomainError = e instanceof Error ? e.message : String(e);
} finally {
removingDomain = false;
}
}
async function saveSettings(event: Event, forceTakeover = false) {
event.preventDefault();
if (!app) return;
savingSettings = true;
settingsError = null;
if (!forceTakeover) slugTakeoverNeeded = null;
try {
const slugChanged = editSlug.trim() !== app.slug;
const updated = await api.apps.update(app.id, {
name: editName.trim() !== app.name ? editName.trim() : undefined,
description:
editDescription !== (app.description ?? '')
? editDescription || null
: undefined,
slug: slugChanged ? editSlug.trim() : undefined,
force_takeover: forceTakeover || undefined
});
if (slugChanged) {
await goto(`${base}/apps/${updated.slug}`, { replaceState: true });
return;
}
app = updated;
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.body) {
const body = e.body as { conflict_kind?: string; current_app?: App };
if (body.conflict_kind === 'historical' && body.current_app) {
slugTakeoverNeeded = body.current_app;
settingsError = null;
return;
}
}
settingsError = e instanceof Error ? e.message : String(e);
} finally {
savingSettings = false;
}
}
async function submitAddMember(event: Event) {
event.preventDefault();
if (!app || !addMemberUserId) return;
addingMember = true;
addMemberError = null;
try {
await api.appMembers.add(app.id, {
user_id: addMemberUserId,
role: addMemberRole
});
addMemberUserId = '';
addMemberRole = 'viewer';
await loadMembers(app.id);
} catch (e) {
addMemberError = e instanceof Error ? e.message : String(e);
} finally {
addingMember = false;
}
}
async function changeMemberRole(member: AppMemberDto, role: AppRole) {
if (!app || member.role === role) return;
roleChangeBusy = member.user_id;
memberActionError = null;
try {
await api.appMembers.setRole(app.id, member.user_id, role);
await loadMembers(app.id);
} catch (e) {
memberActionError = e instanceof Error ? e.message : String(e);
} finally {
roleChangeBusy = null;
}
}
function askRemoveMember(member: AppMemberDto) {
removeMemberError = null;
memberToRemove = member;
}
async function confirmRemoveMember() {
if (!app || !memberToRemove) return;
removingMember = true;
removeMemberError = null;
try {
const removedSelf = !!me && memberToRemove.user_id === me.id;
await api.appMembers.remove(app.id, memberToRemove.user_id);
memberToRemove = null;
if (removedSelf) {
// We just revoked our own access to this app; the next
// fetch of /apps/{slug} would 403. Bounce back to the
// apps list rather than render a broken tab.
await goto(`${base}/apps`);
return;
}
await loadMembers(app.id);
} catch (e) {
removeMemberError = e instanceof Error ? e.message : String(e);
} finally {
removingMember = false;
}
}
function shortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
}
function askDeleteApp() {
deleteAppError = null;
confirmingDeleteApp = true;
}
async function confirmDeleteApp() {
if (!app) return;
deletingApp = true;
deleteAppError = null;
try {
// force=true cascades scripts (and thereby their routes +
// execution logs); domains and slug-history rows cascade off
// the app row itself.
await api.apps.remove(app.id, { force: true });
await goto(`${base}/apps`);
} catch (e) {
deleteAppError = e instanceof Error ? e.message : String(e);
} finally {
deletingApp = false;
}
}
$effect(() => {
void loadApp();
});
// Defense-in-depth: a viewer / editor following a stale link to
// the Settings or Members tab gets bounced back to Scripts. The
// backend still 403s the underlying calls, but no point showing an
// empty tab.
$effect(() => {
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) {
activeTab = 'scripts';
}
});
</script>
{#if loading && !app}
<p class="muted">Loading…</p>
{:else if loadError && !app}
<div class="error">
<strong>Could not load app.</strong>
<p>{loadError}</p>
<a href="{base}/apps">Back to apps</a>
</div>
{:else if app}
<header class="page-header">
<div>
<div class="breadcrumb">
<a href="{base}/apps">Apps</a> / <code>{app.slug}</code>
</div>
<h1>{app.name}</h1>
{#if app.description}<p class="muted">{app.description}</p>{/if}
</div>
</header>
<nav class="tabs">
<button
type="button"
class:active={activeTab === 'scripts'}
onclick={() => (activeTab = 'scripts')}>Scripts ({scripts.length})</button
>
<button
type="button"
class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
>
{#if canAdmin}
<button
type="button"
class:active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
>
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
{/if}
</nav>
{#if activeTab === 'scripts'}
<section>
<div class="row">
<h2>Scripts</h2>
{#if canWrite}
<button
type="button"
onclick={() => (showCreateScript = !showCreateScript)}
>
{showCreateScript ? 'Cancel' : 'New script'}
</button>
{/if}
</div>
{#if showCreateScript && canWrite}
<form class="create-form" onsubmit={submitCreateScript}>
<div class="row">
<label>
<span>Name</span>
<input bind:value={createScriptName} required placeholder="echo" />
</label>
<label>
<span>Description</span>
<input bind:value={createScriptDescription} placeholder="optional" />
</label>
</div>
<label class="full">
<span>Source (Rhai)</span>
<CodeEditor bind:value={createScriptSource} language="rhai" minHeight="14rem" />
</label>
{#if createScriptError}
<div class="error">{createScriptError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingScript}>
{creatingScript ? 'Creating…' : 'Create script'}
</button>
</div>
</form>
{/if}
{#if scripts.length === 0}
<p class="muted">No scripts in this app yet.</p>
{:else}
<ul class="list">
{#each scripts as script (script.id)}
<li>
<a href="{base}/scripts/{script.id}">
<div class="primary">
<strong>{script.name}</strong>
<span class="muted">v{script.version}</span>
</div>
<div class="secondary muted">{script.description ?? '—'}</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'domains'}
<section>
<h2>Domain claims</h2>
<p class="muted">
Hosts this app answers on. Routes inside this app can only bind to
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
</p>
{#if canAdmin}
<form class="create-form inline" onsubmit={submitCreateDomain}>
<input
bind:value={createDomainPattern}
required
placeholder="app.example.com"
/>
<button type="submit" disabled={creatingDomain}>
{creatingDomain ? 'Adding…' : 'Add domain'}
</button>
</form>
{#if createDomainError}
<div class="error">{createDomainError}</div>
{/if}
{/if}
{#if domains.length === 0}
<p class="muted">No domain claims yet.</p>
{:else}
<ul class="list">
{#each domains as d (d.id)}
<li class="domain-row">
<div>
<code>{d.pattern}</code>
<span class="muted">{d.shape}</span>
</div>
{#if canAdmin}
<button
type="button"
class="secondary danger"
onclick={() => askRemoveDomain(d)}
>
Delete
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'members' && canAdmin}
<section>
<h2>Members</h2>
<p class="muted">
Users with explicit access to this app. Instance owners and admins
already have implicit access — they are not listed here. Use the Users
page to invite a <code>member</code> first, then grant them app access
below.
</p>
<form class="create-form" onsubmit={submitAddMember}>
<div class="row">
<label class="grow">
<span>User</span>
<select
bind:value={addMemberUserId}
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
required
>
<option value="" disabled>Pick a member to invite…</option>
{#each eligibleAfterFilter as u (u.id)}
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
{/each}
</select>
</label>
<label>
<span>Role</span>
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
<option value="viewer">viewer</option>
<option value="editor">editor</option>
<option value="app_admin">app admin</option>
</select>
</label>
</div>
{#if eligibleLoadError}
<p class="muted">{eligibleLoadError}</p>
{:else if eligibleAfterFilter.length === 0}
<p class="muted">
No eligible users to invite. Create a <code>member</code> on the Users
page first.
</p>
{/if}
{#if addMemberError}
<div class="error">{addMemberError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
>
{addingMember ? 'Adding…' : 'Add member'}
</button>
</div>
</form>
{#if memberActionError}
<div class="error">{memberActionError}</div>
{/if}
{#if members.length === 0}
<p class="muted">No explicit members yet.</p>
{:else}
<div class="table">
<div class="row head-row">
<div>User</div>
<div>Instance</div>
<div>App role</div>
<div>Joined</div>
<div class="actions-col"></div>
</div>
{#each members as m (m.user_id)}
<div class="row member-row" class:inactive={!m.is_active}>
<div>
<strong>{m.username}</strong>
{#if m.email}<span class="muted">{m.email}</span>{/if}
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
</div>
<div><RoleChip role={m.instance_role} size="sm" /></div>
<div><RoleChip appRole={m.role} size="sm" /></div>
<div>{shortDate(m.created_at)}</div>
<div class="actions-col">
<ActionMenu
label="Member actions for {m.username}"
items={[
{
label: 'Make app admin',
disabled:
m.role === 'app_admin' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'app_admin')
},
{
label: 'Make editor',
disabled:
m.role === 'editor' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'editor')
},
{
label: 'Make viewer',
disabled:
m.role === 'viewer' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'viewer')
},
{
label: 'Remove from app',
danger: true,
onClick: () => askRemoveMember(m)
}
]}
/>
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'settings' && canAdmin}
<section>
<h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
<label>
<span>Name</span>
<input bind:value={editName} required />
</label>
<label>
<span>Description</span>
<input bind:value={editDescription} />
</label>
<label>
<span>Slug</span>
<input
bind:value={editSlug}
required
pattern="[a-z0-9][a-z0-9-]*"
/>
<small class="muted">
Renaming records the old slug as a permanent 301 redirect.
</small>
</label>
{#if slugTakeoverNeeded}
<div class="warning">
<strong>Slug previously redirected.</strong>
<p>
<code>{editSlug}</code> currently redirects to
<code>{slugTakeoverNeeded.slug}</code>. Renaming to it will break old
links.
</p>
<div class="actions">
<button
type="button"
class="secondary"
onclick={() => (slugTakeoverNeeded = null)}
>
Cancel
</button>
<button
type="button"
onclick={(e) => saveSettings(e, true)}
disabled={savingSettings}
>
{savingSettings ? 'Renaming…' : 'Rename anyway'}
</button>
</div>
</div>
{:else if settingsError}
<div class="error">{settingsError}</div>
{/if}
{#if !slugTakeoverNeeded}
<div class="actions">
<button type="submit" disabled={savingSettings}>
{savingSettings ? 'Saving…' : 'Save changes'}
</button>
</div>
{/if}
</form>
<div class="danger-zone">
<h3>Delete app</h3>
<p class="muted">
Permanently removes the app along with all its scripts, routes,
execution logs, and domain claims.
</p>
<button type="button" class="danger" onclick={askDeleteApp}>Delete app</button>
</div>
</section>
{/if}
{#if confirmingDeleteApp}
<ConfirmModal
title="Delete app “{app.name}”"
variant="danger"
confirmLabel="Delete app"
busyLabel="Deleting…"
confirmPhrase={app.slug}
confirmPhrasePrompt="Type the app slug to confirm:"
busy={deletingApp}
onConfirm={confirmDeleteApp}
onCancel={() => (confirmingDeleteApp = false)}
>
<p>
This will <strong>permanently delete</strong> everything inside
<strong>{app.name}</strong>. There is no undo.
</p>
<ul class="impact-list">
<li>
<span>Scripts</span><strong>{scripts.length}</strong>
</li>
<li>
<span>Domain claims</span><strong>{domains.length}</strong>
</li>
<li>
<span>Routes &amp; execution logs</span><strong>all</strong>
</li>
</ul>
{#if domains.length > 0}
<p>The following hosts will stop pointing at this app:</p>
<ul class="impact-list">
{#each domains as d (d.id)}
<li>
<code>{d.pattern}</code><span class="muted">{d.shape}</span>
</li>
{/each}
</ul>
{/if}
{#if deleteAppError}
<p class="modal-error">{deleteAppError}</p>
{/if}
</ConfirmModal>
{/if}
{#if domainToRemove}
<ConfirmModal
title="Delete domain claim"
variant="danger"
confirmLabel="Delete claim"
busyLabel="Deleting…"
busy={removingDomain}
onConfirm={confirmRemoveDomain}
onCancel={() => (domainToRemove = null)}
>
<p>
<strong>{app.name}</strong> will stop answering on
<code>{domainToRemove.pattern}</code>.
</p>
<p class="muted">
Routes already bound to this host are blocked from deletion by the
API; if so, youll see an error here.
</p>
{#if removeDomainError}
<p class="modal-error">{removeDomainError}</p>
{/if}
</ConfirmModal>
{/if}
{#if memberToRemove}
<ConfirmModal
title="Remove {memberToRemove.username} from {app.name}"
variant="danger"
confirmLabel="Remove member"
busyLabel="Removing…"
busy={removingMember}
onConfirm={confirmRemoveMember}
onCancel={() => (memberToRemove = null)}
>
<p>
<strong>{memberToRemove.username}</strong> will lose access to this
app. Their other app memberships and account are untouched.
</p>
{#if removeMemberError}
<p class="modal-error">{removeMemberError}</p>
{/if}
</ConfirmModal>
{/if}
{/if}
<style>
.page-header {
margin-bottom: 1rem;
}
.breadcrumb {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.breadcrumb a {
color: #94a3b8;
text-decoration: none;
}
.breadcrumb a:hover {
color: #e2e8f0;
}
.breadcrumb code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
h2 {
font-size: 1.125rem;
margin: 0 0 1rem;
}
h3 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid #1e293b;
margin-bottom: 1.25rem;
}
.tabs button {
background: transparent;
color: #94a3b8;
border: none;
padding: 0.6rem 1rem;
font: inherit;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button:hover {
color: #e2e8f0;
}
.tabs button.active {
color: #38bdf8;
border-bottom-color: #38bdf8;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
}
button.danger {
background: #7f1d1d;
color: #fecaca;
}
button.secondary.danger {
background: transparent;
color: #fca5a5;
border-color: #7f1d1d;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning {
border: 1px solid #ca8a04;
background: #3f2e07;
color: #fde68a;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form.inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form label.full {
grid-column: 1 / -1;
}
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
flex: 1;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.domain-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
}
.domain-row code {
background: #0b1220;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
.danger-zone {
margin-top: 2rem;
padding: 1rem;
border: 1px solid #7f1d1d;
border-radius: 0.5rem;
background: #1e0a0a;
}
.create-form select {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.create-form .row > label.grow {
grid-column: span 2;
}
.table {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.table .row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
gap: 0.75rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
align-items: center;
margin: 0;
}
.table .head-row {
background: transparent;
padding: 0.25rem 1rem;
color: #64748b;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table .member-row.inactive {
opacity: 0.55;
}
.table .member-row strong {
margin-right: 0.4rem;
}
.table .member-row .muted {
font-size: 0.8rem;
}
.table .actions-col {
display: flex;
justify-content: flex-end;
}
</style>