Files
PiCloud/dashboard/src/routes/apps/[slug]/+page.svelte
MechaCat02 ff4f443531 feat(v1.1.8): F3 realtime auth_mode = 'session' (migration 0033)
Migration 0033 widens topics.auth_mode CHECK to include 'session'
alongside 'public' and 'token'.

TopicAuthMode enum gains a Session variant (as_str + from_db
extended uniformly).

RealtimeAuthorityImpl now takes Arc<dyn UsersService> as a third
constructor arg. The Session branch of authorize_subscribe
delegates to UsersService::verify_session_for_realtime(app_id,
token):
  * Returns Some(user) → allow. The service bumps the sliding TTL
    on success.
  * Returns None → Unauthorized.
  * Defense-in-depth: even though verify_session_for_realtime
    already enforces cross-app isolation, the branch re-checks
    user.app_id == app_id.

Tests added (4 new cases): valid session token allows; missing
token is Unauthorized; wrong token is Unauthorized; cross-app
session token is Unauthorized. All 12 realtime_authority tests
pass.

Dashboard: TopicAuthMode TypeScript union widened to include
'session'; the topic create + edit forms gain a third radio option
labeled "session — requires a per-app user session minted by
users::login (v1.1.8)".

picloud binary: construction order reshuffled so users is built
before realtime_authority. app_secrets_repo is now .clone()'d into
the pubsub realtime wiring so the original Arc can be re-used by
realtime_authority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:00:47 +02:00

2077 lines
54 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,
type Trigger,
type Topic,
type TopicAuthMode,
type SecretListItem
} 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' | 'triggers' | 'topics' | 'secrets';
// Common IANA timezones offered in the cron form dropdown. Not
// exhaustive — the backend validates any IANA name via chrono-tz.
const COMMON_TIMEZONES = [
'UTC',
'America/Los_Angeles',
'America/Denver',
'America/Chicago',
'America/New_York',
'America/Sao_Paulo',
'Europe/London',
'Europe/Berlin',
'Europe/Paris',
'Europe/Moscow',
'Asia/Kolkata',
'Asia/Shanghai',
'Asia/Tokyo',
'Australia/Sydney'
];
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[]>([]);
/// v1.1.1 dead-letters surface — design notes §4 mandates the
/// dashboard surface this since there's no default handler.
let unresolvedDeadLetters = $state<number>(0);
async function loadDeadLetterCount(idOrSlug: string) {
try {
const r = await api.deadLetters.count(idOrSlug);
unresolvedDeadLetters = r.unresolved;
} catch {
// Non-fatal: the page renders fine without the badge if
// the count endpoint is unreachable (e.g. older server).
unresolvedDeadLetters = 0;
}
}
// 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);
// v1.1.3: endpoint (default — handles routes/triggers) vs module
// (library imported by other scripts). Modules cannot be bound to
// routes or used as trigger targets.
let createScriptKind = $state<'endpoint' | 'module'>('endpoint');
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);
// Triggers tab (v1.1.4 — cron triggers). Admin-gated, like Members.
let triggers = $state<Trigger[]>([]);
let createCronScriptId = $state('');
let createCronSchedule = $state('0 0 9 * * MON-FRI');
let createCronTimezone = $state('UTC');
let creatingCron = $state(false);
let createCronError = $state<string | null>(null);
// Pub/Sub triggers (v1.1.5).
let createPubsubScriptId = $state('');
let createPubsubTopic = $state('');
let creatingPubsub = $state(false);
let createPubsubError = $state<string | null>(null);
// Email triggers (v1.1.7).
let createEmailScriptId = $state('');
let createEmailSecret = $state('');
let creatingEmail = $state(false);
let createEmailError = $state<string | null>(null);
let triggerToRemove = $state<Trigger | null>(null);
let removingTrigger = $state(false);
// Endpoint scripts only — modules can't be trigger targets.
const endpointScripts = $derived(scripts.filter((s) => s.kind === 'endpoint'));
async function loadTriggers(idOrSlug: string) {
try {
const r = await api.triggers.list(idOrSlug);
triggers = r.triggers;
} catch {
triggers = [];
}
}
async function submitCreateCron(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingCron = true;
createCronError = null;
try {
await api.triggers.createCron(app.id, {
script_id: createCronScriptId,
schedule: createCronSchedule.trim(),
timezone: createCronTimezone
});
createCronScriptId = '';
await loadTriggers(app.id);
} catch (err) {
createCronError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingCron = false;
}
}
async function submitCreatePubsub(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingPubsub = true;
createPubsubError = null;
try {
await api.triggers.createPubsub(app.id, {
script_id: createPubsubScriptId,
topic_pattern: createPubsubTopic.trim()
});
createPubsubScriptId = '';
createPubsubTopic = '';
await loadTriggers(app.id);
} catch (err) {
createPubsubError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingPubsub = false;
}
}
async function submitCreateEmail(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingEmail = true;
createEmailError = null;
try {
await api.triggers.createEmail(app.id, {
script_id: createEmailScriptId,
inbound_secret: createEmailSecret.trim() === '' ? null : createEmailSecret
});
createEmailScriptId = '';
createEmailSecret = '';
await loadTriggers(app.id);
} catch (err) {
createEmailError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingEmail = false;
}
}
// The inbound-email webhook URL for a given email trigger (shown so
// the operator can configure their provider).
function emailInboundUrl(triggerId: string): string {
if (!app) return '';
return `${window.location.origin}/api/v1/email-inbound/${app.id}/${triggerId}`;
}
async function confirmRemoveTrigger() {
if (!app || !triggerToRemove) return;
removingTrigger = true;
try {
await api.triggers.remove(app.id, triggerToRemove.id);
triggerToRemove = null;
await loadTriggers(app.id);
} catch (err) {
createCronError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
removingTrigger = false;
}
}
// Topics tab (v1.1.6 — externally-subscribable realtime topics). Admin-gated.
let topics = $state<Topic[]>([]);
let createTopicName = $state('');
let createTopicExternal = $state(false);
let createTopicAuthMode = $state<TopicAuthMode>('public');
let creatingTopic = $state(false);
let createTopicError = $state<string | null>(null);
// Edit modal.
let topicToEdit = $state<Topic | null>(null);
let editTopicExternal = $state(false);
let editTopicAuthMode = $state<TopicAuthMode>('public');
let savingTopic = $state(false);
let editTopicError = $state<string | null>(null);
// Flipping internal → external is the security-sensitive change.
const editFlipToExternal = $derived(
!!topicToEdit && !topicToEdit.external_subscribable && editTopicExternal
);
// Delete confirm.
let topicToRemove = $state<Topic | null>(null);
let removingTopic = $state(false);
async function loadTopics(idOrSlug: string) {
try {
const r = await api.topics.list(idOrSlug);
topics = r.topics;
} catch {
topics = [];
}
}
async function submitCreateTopic(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingTopic = true;
createTopicError = null;
try {
await api.topics.create(app.id, {
name: createTopicName.trim(),
external_subscribable: createTopicExternal,
auth_mode: createTopicAuthMode
});
createTopicName = '';
createTopicExternal = false;
createTopicAuthMode = 'public';
await loadTopics(app.id);
} catch (err) {
createTopicError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingTopic = false;
}
}
function openEditTopic(t: Topic) {
topicToEdit = t;
editTopicExternal = t.external_subscribable;
editTopicAuthMode = t.auth_mode;
editTopicError = null;
}
async function confirmEditTopic() {
if (!app || !topicToEdit) return;
savingTopic = true;
editTopicError = null;
try {
await api.topics.update(app.id, topicToEdit.name, {
external_subscribable: editTopicExternal,
auth_mode: editTopicAuthMode
});
topicToEdit = null;
await loadTopics(app.id);
} catch (err) {
editTopicError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
savingTopic = false;
}
}
async function confirmRemoveTopic() {
if (!app || !topicToRemove) return;
removingTopic = true;
try {
await api.topics.remove(app.id, topicToRemove.name);
topicToRemove = null;
await loadTopics(app.id);
} catch (err) {
createTopicError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
removingTopic = false;
}
}
// Secrets tab (v1.1.7). The dashboard only ever sees names +
// last-modified — values never leave the server. The create form's
// value input is masked by default; revealing it requires a confirm.
let secrets = $state<SecretListItem[]>([]);
let createSecretName = $state('');
let createSecretValue = $state('');
let showSecretValue = $state(false);
let revealConfirm = $state(false);
let creatingSecret = $state(false);
let createSecretError = $state<string | null>(null);
let secretToRemove = $state<SecretListItem | null>(null);
let removingSecret = $state(false);
// True when the name already exists — set is an overwrite.
const secretNameExists = $derived(
secrets.some((s) => s.name === createSecretName.trim())
);
async function loadSecrets(idOrSlug: string) {
try {
const r = await api.secrets.list(idOrSlug);
secrets = r.secrets;
} catch {
secrets = [];
}
}
function toggleShowSecretValue(e: Event) {
const target = e.currentTarget as HTMLInputElement;
if (target.checked) {
// Revealing a secret on screen is sensitive — gate behind a
// confirm. Revert the checkbox until the user confirms.
target.checked = false;
revealConfirm = true;
} else {
showSecretValue = false;
}
}
function confirmRevealSecret() {
showSecretValue = true;
revealConfirm = false;
}
async function submitCreateSecret(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingSecret = true;
createSecretError = null;
try {
await api.secrets.set(app.id, createSecretName.trim(), createSecretValue);
createSecretName = '';
createSecretValue = '';
showSecretValue = false;
await loadSecrets(app.id);
} catch (err) {
createSecretError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingSecret = false;
}
}
async function confirmRemoveSecret() {
if (!app || !secretToRemove) return;
removingSecret = true;
try {
await api.secrets.remove(app.id, secretToRemove.name);
secretToRemove = null;
await loadSecrets(app.id);
} catch (err) {
createSecretError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
removingSecret = false;
}
}
// 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),
loadDeadLetterCount(app.id)
];
if (canAdmin) {
loaders.push(
loadMembers(app.id),
loadEligibleUsers(),
loadTriggers(app.id),
loadTopics(app.id),
loadSecrets(app.id)
);
}
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,
kind: createScriptKind
});
showCreateScript = false;
createScriptName = '';
createScriptDescription = '';
createScriptSource = SAMPLE_SOURCE;
createScriptKind = 'endpoint';
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 === 'triggers' ||
activeTab === 'topics' ||
activeTab === 'secrets')
) {
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 === 'triggers'}
onclick={() => (activeTab = 'triggers')}>Triggers ({triggers.length})</button
>
<button
type="button"
class:active={activeTab === 'topics'}
onclick={() => (activeTab = 'topics')}>Topics ({topics.length})</button
>
<button
type="button"
class:active={activeTab === 'secrets'}
onclick={() => (activeTab = 'secrets')}>Secrets ({secrets.length})</button
>
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
<a
class="tab-link"
href="{base}/apps/{slug}/users"
title="Users — per-app end users managed by the users::* SDK"
>
Users
</a>
<a
class="tab-link"
href="{base}/apps/{slug}/files"
title="Files — browse and delete stored blobs by collection"
>
Files
</a>
<a
class="tab-link"
href="{base}/apps/{slug}/dead-letters"
title="Dead letters — replay or resolve events that exhausted their retry policy"
>
Dead letters
{#if unresolvedDeadLetters > 0}
<span class="dl-badge">{unresolvedDeadLetters}</span>
{/if}
</a>
{/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>Kind</span>
<select bind:value={createScriptKind}>
<option value="endpoint">Endpoint (handles HTTP / triggers)</option>
<option value="module">Module (imported by other scripts)</option>
</select>
</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 createScriptKind === 'module'}
<p class="muted small">
Modules expose <code>fn</code> and <code>const</code> declarations to other
scripts via <code>import "name" as alias;</code>. They cannot be bound to
routes or used as trigger targets.
</p>
{/if}
{#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>
{#if script.kind === 'module'}
<span class="kind-badge kind-module" title="Library imported by other scripts">module</span>
{:else}
<span class="kind-badge kind-endpoint" title="Handles HTTP routes and trigger events">endpoint</span>
{/if}
</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 === 'triggers' && canAdmin}
<section>
<h2>Cron triggers</h2>
<p class="muted">
Run an endpoint script on a schedule. Schedules are 6-field cron
expressions (with seconds): <code>sec min hour day-of-month month day-of-week</code>.
The timezone disambiguates schedules like "every weekday at 9am".
</p>
<form class="create-form" onsubmit={submitCreateCron}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createCronScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label>
<span>Schedule</span>
<input
bind:value={createCronSchedule}
required
placeholder="0 0 9 * * MON-FRI"
/>
</label>
<label>
<span>Timezone</span>
<select bind:value={createCronTimezone}>
{#each COMMON_TIMEZONES as tz (tz)}
<option value={tz}>{tz}</option>
{/each}
</select>
</label>
</div>
{#if endpointScripts.length === 0}
<p class="muted small">
This app has no endpoint scripts yet — create one first (modules
can't be trigger targets).
</p>
{/if}
{#if createCronError}
<div class="error">{createCronError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingCron || !createCronScriptId}>
{creatingCron ? 'Creating…' : 'Create cron trigger'}
</button>
</div>
</form>
<h2>Pub/Sub triggers</h2>
<p class="muted">
Subscribe an endpoint script to durable pub/sub messages. Topic
patterns are an exact topic (<code>user.created</code>), a prefix
wildcard (<code>user.*</code>), or <code>*</code> for every topic.
</p>
<form class="create-form" onsubmit={submitCreatePubsub}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createPubsubScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label>
<span>Topic pattern</span>
<input bind:value={createPubsubTopic} required placeholder="user.*" />
</label>
</div>
{#if createPubsubError}
<div class="error">{createPubsubError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={creatingPubsub || !createPubsubScriptId || !createPubsubTopic.trim()}
>
{creatingPubsub ? 'Creating…' : 'Create pub/sub trigger'}
</button>
</div>
</form>
<h2>Email triggers</h2>
<p class="muted">
Fire an endpoint script when your email provider POSTs an inbound
message to PiCloud. Configure your provider (Mailgun / Postmark /
SendGrid / SES) to POST the generic JSON shape below to the trigger's
webhook URL. Set a shared secret to require an
<code>X-Picloud-Signature</code> HMAC-SHA256 (hex of the request body);
leave it blank to accept unsigned POSTs (URL secrecy only).
</p>
<details class="muted small">
<summary>Expected inbound JSON shape</summary>
<pre>{`{
"from": "sender@external.com",
"to": ["alice@myapp.com"],
"cc": [],
"subject": "...",
"text": "...",
"html": "...",
"message_id": "<abc@external.com>"
}`}</pre>
</details>
<form class="create-form" onsubmit={submitCreateEmail}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createEmailScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label class="grow">
<span>Inbound HMAC secret (optional)</span>
<input
type="password"
bind:value={createEmailSecret}
placeholder="leave blank to accept unsigned POSTs"
/>
</label>
</div>
{#if createEmailError}
<div class="error">{createEmailError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingEmail || !createEmailScriptId}>
{creatingEmail ? 'Creating…' : 'Create email trigger'}
</button>
</div>
</form>
{#if triggers.length === 0}
<p class="muted">No triggers in this app yet.</p>
{:else}
<ul class="list">
{#each triggers as t (t.id)}
<li class="domain-row">
<div>
<span class="kind-badge">{t.kind}</span>
{#if t.details.kind === 'cron'}
<code>{t.details.schedule}</code>
<span class="muted">{t.details.timezone}</span>
<span class="muted small">
last fired: {t.details.last_fired_at ?? 'never'}
</span>
{:else if t.details.kind === 'kv' || t.details.kind === 'docs' || t.details.kind === 'files'}
<code>{t.details.collection_glob}</code>
<span class="muted">{t.details.ops.join(', ') || 'any op'}</span>
{:else if t.details.kind === 'pubsub'}
<code>{t.details.topic_pattern}</code>
{:else if t.details.kind === 'email'}
<span class="muted">
{t.details.has_inbound_secret ? 'signed (HMAC)' : 'unsigned'}
</span>
<code class="webhook-url">{emailInboundUrl(t.id)}</code>
{/if}
<span class="muted small">{t.script_id}</span>
</div>
<button
type="button"
class="secondary danger"
onclick={() => (triggerToRemove = t)}
>
Delete
</button>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'topics' && canAdmin}
<section>
<h2>Realtime topics</h2>
<p class="muted">
Pub/sub topics are <strong>internal-only</strong> by default — scripts
subscribe via triggers, browsers can't. Register a topic here and mark it
<strong>externally subscribable</strong> to let frontend clients connect over
SSE at <code>/realtime/topics/&lt;name&gt;</code>. <code>public</code> topics
need no auth; <code>token</code> topics require a subscriber token minted by a
script via <code>pubsub::subscriber_token</code>.
</p>
<form class="create-form" onsubmit={submitCreateTopic}>
<div class="row">
<label class="grow">
<span>Topic name</span>
<input bind:value={createTopicName} required placeholder="chat-room-updates" />
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" bind:checked={createTopicExternal} />
<span>Externally subscribable (allow browser SSE clients to subscribe)</span>
</label>
{#if createTopicExternal}
<fieldset class="auth-mode">
<legend>Auth mode</legend>
<label class="radio-row">
<input type="radio" value="public" bind:group={createTopicAuthMode} />
<span><strong>public</strong> — anyone with the URL can subscribe</span>
</label>
<label class="radio-row">
<input type="radio" value="token" bind:group={createTopicAuthMode} />
<span><strong>token</strong> — requires a valid subscriber token</span>
</label>
<label class="radio-row">
<input type="radio" value="session" bind:group={createTopicAuthMode} />
<span>
<strong>session</strong> — requires a per-app user session
minted by <code>users::login</code> (v1.1.8)
</span>
</label>
</fieldset>
{/if}
{#if createTopicError}
<div class="error">{createTopicError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingTopic || !createTopicName.trim()}>
{creatingTopic ? 'Creating…' : 'Register topic'}
</button>
</div>
</form>
{#if topics.length === 0}
<p class="muted">No registered topics in this app yet.</p>
{:else}
<ul class="list">
{#each topics as t (t.name)}
<li class="domain-row">
<div>
<code>{t.name}</code>
{#if t.external_subscribable}
<span class="badge badge-external" title="Browser clients can subscribe over SSE">
external
</span>
<span class="badge badge-auth">{t.auth_mode}</span>
{:else}
<span class="badge badge-internal" title="Internal-only: scripts subscribe via triggers">
internal
</span>
{/if}
<span class="muted small">· {shortDate(t.created_at)}</span>
</div>
<div class="topic-actions">
<button type="button" class="secondary" onclick={() => openEditTopic(t)}>
Edit
</button>
<button
type="button"
class="secondary danger"
onclick={() => (topicToRemove = t)}
>
Delete
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'secrets' && canAdmin}
<section>
<h2>Secrets</h2>
<p class="muted">
Encrypted per-app configuration (API keys, OAuth tokens, webhook signing
keys), available to scripts as <code>secrets::get("name")</code>. Values are
encrypted at rest with the process master key and
<strong>never leave the server</strong> — this list shows names and
last-modified times only.
</p>
<form class="create-form" onsubmit={submitCreateSecret}>
<div class="row">
<label class="grow">
<span>Name</span>
<input bind:value={createSecretName} required placeholder="stripe_key" />
</label>
</div>
<label class="grow">
<span>Value</span>
{#if showSecretValue}
<input type="text" bind:value={createSecretValue} placeholder="sk_live_…" />
{:else}
<input type="password" bind:value={createSecretValue} placeholder="sk_live_…" />
{/if}
</label>
<label class="checkbox-row">
<input type="checkbox" checked={showSecretValue} onchange={toggleShowSecretValue} />
<span>Show value</span>
</label>
{#if secretNameExists && createSecretName.trim()}
<p class="muted small">
A secret named <code>{createSecretName.trim()}</code> already exists — saving
overwrites it.
</p>
{/if}
{#if createSecretError}
<div class="error">{createSecretError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingSecret || !createSecretName.trim()}>
{creatingSecret ? 'Saving…' : secretNameExists ? 'Overwrite secret' : 'Save secret'}
</button>
</div>
</form>
{#if secrets.length === 0}
<p class="muted">No secrets in this app yet.</p>
{:else}
<ul class="list">
{#each secrets as s (s.name)}
<li class="domain-row">
<div>
<code>{s.name}</code>
<span class="muted small">· updated {shortDate(s.updated_at)}</span>
</div>
<div class="topic-actions">
<button
type="button"
class="secondary danger"
onclick={() => (secretToRemove = s)}
>
Delete
</button>
</div>
</li>
{/each}
</ul>
{/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 triggerToRemove}
<ConfirmModal
title="Delete trigger"
variant="danger"
confirmLabel="Delete trigger"
busyLabel="Deleting…"
busy={removingTrigger}
onConfirm={confirmRemoveTrigger}
onCancel={() => (triggerToRemove = null)}
>
<p>
This {triggerToRemove.kind} trigger will stop firing. The target
script is not affected.
</p>
</ConfirmModal>
{/if}
{#if topicToEdit}
<ConfirmModal
title="Edit topic “{topicToEdit.name}”"
confirmLabel="Save changes"
busyLabel="Saving…"
busy={savingTopic}
onConfirm={confirmEditTopic}
onCancel={() => (topicToEdit = null)}
>
<label class="checkbox-row">
<input type="checkbox" bind:checked={editTopicExternal} />
<span>Externally subscribable</span>
</label>
{#if editTopicExternal}
<fieldset class="auth-mode">
<legend>Auth mode</legend>
<label class="radio-row">
<input type="radio" value="public" bind:group={editTopicAuthMode} />
<span><strong>public</strong> — anyone with the URL can subscribe</span>
</label>
<label class="radio-row">
<input type="radio" value="token" bind:group={editTopicAuthMode} />
<span><strong>token</strong> — requires a valid subscriber token</span>
</label>
<label class="radio-row">
<input type="radio" value="session" bind:group={editTopicAuthMode} />
<span>
<strong>session</strong> — requires a per-app user session minted
by <code>users::login</code> (v1.1.8)
</span>
</label>
</fieldset>
{/if}
{#if editFlipToExternal}
<div class="warning">
Marking <strong>{topicToEdit.name}</strong> externally-subscribable means
anyone with the URL can subscribe to this topic (if auth_mode is
<code>public</code>) or anyone with a valid token can subscribe (if
auth_mode is <code>token</code>). Are you sure?
</div>
{/if}
{#if editTopicError}
<p class="modal-error">{editTopicError}</p>
{/if}
</ConfirmModal>
{/if}
{#if topicToRemove}
<ConfirmModal
title="Delete topic “{topicToRemove.name}”"
variant="danger"
confirmLabel="Delete topic"
busyLabel="Deleting…"
busy={removingTopic}
onConfirm={confirmRemoveTopic}
onCancel={() => (topicToRemove = null)}
>
<p>
Unregistering <code>{topicToRemove.name}</code> disconnects any live SSE
subscribers immediately. Scripts can still <code>publish_durable</code> to
it (internal triggers keep working) — it just won't be externally
subscribable.
</p>
</ConfirmModal>
{/if}
{#if revealConfirm}
<ConfirmModal
title="Reveal secret value?"
confirmLabel="Show value"
onConfirm={confirmRevealSecret}
onCancel={() => (revealConfirm = false)}
>
<p>
The value you type will be shown in plain text on screen. Make sure no one
is looking over your shoulder and that screen-sharing is off.
</p>
</ConfirmModal>
{/if}
{#if secretToRemove}
<ConfirmModal
title="Delete secret “{secretToRemove.name}”"
variant="danger"
confirmLabel="Delete secret"
busyLabel="Deleting…"
busy={removingSecret}
onConfirm={confirmRemoveSecret}
onCancel={() => (secretToRemove = null)}
>
<p>
Deleting <code>{secretToRemove.name}</code> is permanent. Any script calling
<code>secrets::get("{secretToRemove.name}")</code> will get <code>()</code>
until you set it again.
</p>
</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;
}
.tabs .tab-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #94a3b8;
text-decoration: none;
padding: 0.6rem 1rem;
margin-left: auto;
border-bottom: 2px solid transparent;
font: inherit;
}
.tabs .tab-link:hover {
color: #e2e8f0;
}
.dl-badge {
display: inline-block;
min-width: 1.25rem;
padding: 0.1rem 0.4rem;
background: #ef4444;
color: #fff;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
}
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;
}
.kind-badge {
display: inline-block;
padding: 0 0.45rem;
margin-left: 0.5rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.5;
}
.kind-endpoint {
background: #1e3a5f;
color: #93c5fd;
}
.kind-module {
background: #3f2e7d;
color: #c4b5fd;
}
.small {
font-size: 0.85rem;
}
.checkbox-row,
.radio-row {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.checkbox-row input,
.radio-row input {
flex: none;
width: auto;
}
.auth-mode {
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.auth-mode legend {
font-size: 0.8rem;
color: #94a3b8;
padding: 0 0.3rem;
}
.topic-actions {
display: flex;
gap: 0.5rem;
}
.badge {
display: inline-block;
padding: 0 0.45rem;
margin-left: 0.4rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.5;
}
.badge-external {
background: #064e3b;
color: #6ee7b7;
}
.badge-internal {
background: #334155;
color: #cbd5e1;
}
.badge-auth {
background: #1e3a5f;
color: #93c5fd;
}
</style>