Trigger.enabled is a boolean on the DTO and the backend supports disabled triggers, but the trigger list never showed the state. Operators couldn't tell from the UI whether a trigger was paused. Add a small "• disabled" pill next to the kind badge when t.enabled === false, with a tooltip explaining the consequence. No PATCH endpoint yet for enabled, so toggle UI is tracked separately as a follow-up. AUDIT.md anchor: F-U-006. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2515 lines
69 KiB
Svelte
2515 lines
69 KiB
Svelte
<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);
|
||
/// F-U-014: the canonical public origin (from `/version`). Used to
|
||
/// build webhook URLs the operator shares with external systems so
|
||
/// they don't accidentally inherit the admin's LAN address.
|
||
let publicBaseUrl = $state<string | null>(null);
|
||
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);
|
||
// Queue triggers (v1.1.9).
|
||
let createQueueScriptId = $state('');
|
||
let createQueueName = $state('');
|
||
let createQueueVisibilityTimeout = $state(30);
|
||
let createQueueMaxAttempts = $state(3);
|
||
let creatingQueue = $state(false);
|
||
let createQueueError = $state<string | null>(null);
|
||
// F-U-001: KV / Docs / Files / Dead-letter trigger forms.
|
||
let createKvScriptId = $state('');
|
||
let createKvCollectionGlob = $state('*');
|
||
let createKvOps = $state<{ insert: boolean; update: boolean; delete: boolean }>({
|
||
insert: true,
|
||
update: true,
|
||
delete: true
|
||
});
|
||
let creatingKv = $state(false);
|
||
let createKvError = $state<string | null>(null);
|
||
let createDocsScriptId = $state('');
|
||
let createDocsCollectionGlob = $state('*');
|
||
let createDocsOps = $state<{ create: boolean; update: boolean; delete: boolean }>({
|
||
create: true,
|
||
update: true,
|
||
delete: true
|
||
});
|
||
let creatingDocs = $state(false);
|
||
let createDocsError = $state<string | null>(null);
|
||
let createFilesScriptId = $state('');
|
||
let createFilesCollectionGlob = $state('*');
|
||
let createFilesOps = $state<{ create: boolean; update: boolean; delete: boolean }>({
|
||
create: true,
|
||
update: true,
|
||
delete: true
|
||
});
|
||
let creatingFiles = $state(false);
|
||
let createFilesError = $state<string | null>(null);
|
||
let createDeadLetterScriptId = $state('');
|
||
let createDeadLetterSourceFilter = $state('');
|
||
let createDeadLetterTriggerIdFilter = $state('');
|
||
let createDeadLetterScriptIdFilter = $state('');
|
||
let creatingDeadLetter = $state(false);
|
||
let createDeadLetterError = $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;
|
||
}
|
||
}
|
||
|
||
// F-U-001: handlers for kv / docs / files / dead-letter trigger creation.
|
||
function selectedOps<T extends Record<string, boolean>>(ops: T): Array<keyof T> {
|
||
return (Object.keys(ops) as Array<keyof T>).filter((k) => ops[k]);
|
||
}
|
||
|
||
async function submitCreateKv(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
if (!app) return;
|
||
creatingKv = true;
|
||
createKvError = null;
|
||
try {
|
||
await api.triggers.createKv(app.id, {
|
||
script_id: createKvScriptId,
|
||
collection_glob: createKvCollectionGlob.trim(),
|
||
ops: selectedOps(createKvOps) as Array<'insert' | 'update' | 'delete'>
|
||
});
|
||
createKvScriptId = '';
|
||
createKvCollectionGlob = '*';
|
||
await loadTriggers(app.id);
|
||
} catch (err) {
|
||
createKvError =
|
||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
creatingKv = false;
|
||
}
|
||
}
|
||
|
||
async function submitCreateDocs(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
if (!app) return;
|
||
creatingDocs = true;
|
||
createDocsError = null;
|
||
try {
|
||
await api.triggers.createDocs(app.id, {
|
||
script_id: createDocsScriptId,
|
||
collection_glob: createDocsCollectionGlob.trim(),
|
||
ops: selectedOps(createDocsOps) as Array<'create' | 'update' | 'delete'>
|
||
});
|
||
createDocsScriptId = '';
|
||
createDocsCollectionGlob = '*';
|
||
await loadTriggers(app.id);
|
||
} catch (err) {
|
||
createDocsError =
|
||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
creatingDocs = false;
|
||
}
|
||
}
|
||
|
||
async function submitCreateFiles(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
if (!app) return;
|
||
creatingFiles = true;
|
||
createFilesError = null;
|
||
try {
|
||
await api.triggers.createFiles(app.id, {
|
||
script_id: createFilesScriptId,
|
||
collection_glob: createFilesCollectionGlob.trim(),
|
||
ops: selectedOps(createFilesOps) as Array<'create' | 'update' | 'delete'>
|
||
});
|
||
createFilesScriptId = '';
|
||
createFilesCollectionGlob = '*';
|
||
await loadTriggers(app.id);
|
||
} catch (err) {
|
||
createFilesError =
|
||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
creatingFiles = false;
|
||
}
|
||
}
|
||
|
||
async function submitCreateDeadLetter(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
if (!app) return;
|
||
creatingDeadLetter = true;
|
||
createDeadLetterError = null;
|
||
try {
|
||
await api.triggers.createDeadLetter(app.id, {
|
||
script_id: createDeadLetterScriptId,
|
||
source_filter:
|
||
createDeadLetterSourceFilter.trim() === '' ? null : createDeadLetterSourceFilter.trim(),
|
||
trigger_id_filter:
|
||
createDeadLetterTriggerIdFilter.trim() === ''
|
||
? null
|
||
: createDeadLetterTriggerIdFilter.trim(),
|
||
script_id_filter:
|
||
createDeadLetterScriptIdFilter.trim() === ''
|
||
? null
|
||
: createDeadLetterScriptIdFilter.trim()
|
||
});
|
||
createDeadLetterScriptId = '';
|
||
createDeadLetterSourceFilter = '';
|
||
createDeadLetterTriggerIdFilter = '';
|
||
createDeadLetterScriptIdFilter = '';
|
||
await loadTriggers(app.id);
|
||
} catch (err) {
|
||
createDeadLetterError =
|
||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
creatingDeadLetter = false;
|
||
}
|
||
}
|
||
|
||
async function submitCreateQueue(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
if (!app) return;
|
||
creatingQueue = true;
|
||
createQueueError = null;
|
||
try {
|
||
await api.triggers.createQueue(app.id, {
|
||
script_id: createQueueScriptId,
|
||
queue_name: createQueueName.trim(),
|
||
visibility_timeout_secs: createQueueVisibilityTimeout,
|
||
retry_max_attempts: createQueueMaxAttempts
|
||
});
|
||
createQueueScriptId = '';
|
||
createQueueName = '';
|
||
createQueueVisibilityTimeout = 30;
|
||
createQueueMaxAttempts = 3;
|
||
await loadTriggers(app.id);
|
||
} catch (err) {
|
||
createQueueError =
|
||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
creatingQueue = false;
|
||
}
|
||
}
|
||
|
||
// The inbound-email webhook URL for a given email trigger (shown so
|
||
// the operator can configure their provider).
|
||
// F-U-014: prefer the server-known public_base_url (from /version)
|
||
// so the URL we hand the operator works from outside the admin's
|
||
// network. Fall back to window.location.origin only if /version
|
||
// hasn't loaded yet (visibly distinguishable in the UI).
|
||
function emailInboundUrl(triggerId: string): string {
|
||
if (!app) return '';
|
||
const origin = publicBaseUrl ?? window.location.origin;
|
||
return `${origin.replace(/\/$/, '')}/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);
|
||
// F-S-014: warn whenever the resulting (external, auth_mode) pair is
|
||
// strictly more permissive than the current one — not just on the
|
||
// internal → external flip. The order least → most permissive is:
|
||
// internal (any auth) < external+session < external+token < external+public
|
||
const editPermissivenessChange = $derived.by(() => {
|
||
if (!topicToEdit) return false;
|
||
const rank = (external: boolean, mode: TopicAuthMode) => {
|
||
if (!external) return 0;
|
||
if (mode === 'session') return 1;
|
||
if (mode === 'token') return 2;
|
||
return 3; // public
|
||
};
|
||
const before = rank(topicToEdit.external_subscribable, topicToEdit.auth_mode);
|
||
const after = rank(editTopicExternal, editTopicAuthMode);
|
||
return after > before;
|
||
});
|
||
// Kept for narrow callers; semantics now covered by editPermissivenessChange.
|
||
const editFlipToExternal = $derived(editPermissivenessChange);
|
||
// 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 {
|
||
// F-U-014: fire the /version request alongside the app fetch so
|
||
// emailInboundUrl can prefer the server-known public_base_url.
|
||
api
|
||
.version()
|
||
.then((v) => (publicBaseUrl = v.public_base_url || null))
|
||
.catch(() => (publicBaseUrl = null));
|
||
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}/queues"
|
||
title="Queues — depth of per-app durable queues (v1.1.9, read-only)"
|
||
>
|
||
Queues
|
||
</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>
|
||
|
||
<h3>Queue:receive trigger (v1.1.9)</h3>
|
||
<p class="muted">
|
||
Register a script as the consumer for a per-app durable queue. Exactly
|
||
one consumer per queue is allowed — the registration will be rejected
|
||
if another consumer already exists. Use the
|
||
<a href="{base}/apps/{slug}/queues">Queues</a> tab for a read-only view
|
||
of message depths.
|
||
</p>
|
||
<form class="create-form" onsubmit={submitCreateQueue}>
|
||
<div class="row">
|
||
<label>
|
||
<span>Target script</span>
|
||
<select bind:value={createQueueScriptId} 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>Queue name</span>
|
||
<input
|
||
type="text"
|
||
bind:value={createQueueName}
|
||
placeholder="payments.process"
|
||
required
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div class="row">
|
||
<label>
|
||
<span>Visibility timeout (5–3600s)</span>
|
||
<input
|
||
type="number"
|
||
bind:value={createQueueVisibilityTimeout}
|
||
min="5"
|
||
max="3600"
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>Max attempts (1–20)</span>
|
||
<input
|
||
type="number"
|
||
bind:value={createQueueMaxAttempts}
|
||
min="1"
|
||
max="20"
|
||
required
|
||
/>
|
||
</label>
|
||
</div>
|
||
{#if createQueueError}
|
||
<div class="error">{createQueueError}</div>
|
||
{/if}
|
||
<div class="actions">
|
||
<button
|
||
type="submit"
|
||
disabled={creatingQueue || !createQueueScriptId || !createQueueName}
|
||
>
|
||
{creatingQueue ? 'Creating…' : 'Create queue:receive trigger'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<!-- F-U-001: KV / Docs / Files / Dead-letter trigger create forms. -->
|
||
<h3>Create kv:event trigger</h3>
|
||
<form class="create-form" onsubmit={submitCreateKv}>
|
||
<div class="row">
|
||
<label>
|
||
<span>Target script</span>
|
||
<select bind:value={createKvScriptId} 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>Collection glob</span>
|
||
<input
|
||
type="text"
|
||
bind:value={createKvCollectionGlob}
|
||
placeholder="users.* or *"
|
||
required
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div class="row">
|
||
<span>Ops:</span>
|
||
<label><input type="checkbox" bind:checked={createKvOps.insert} /> insert</label>
|
||
<label><input type="checkbox" bind:checked={createKvOps.update} /> update</label>
|
||
<label><input type="checkbox" bind:checked={createKvOps.delete} /> delete</label>
|
||
</div>
|
||
{#if createKvError}<div class="error">{createKvError}</div>{/if}
|
||
<div class="actions">
|
||
<button type="submit" disabled={creatingKv || !createKvScriptId || !createKvCollectionGlob}>
|
||
{creatingKv ? 'Creating…' : 'Create kv:event trigger'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<h3>Create docs:event trigger</h3>
|
||
<form class="create-form" onsubmit={submitCreateDocs}>
|
||
<div class="row">
|
||
<label>
|
||
<span>Target script</span>
|
||
<select bind:value={createDocsScriptId} 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>Collection glob</span>
|
||
<input
|
||
type="text"
|
||
bind:value={createDocsCollectionGlob}
|
||
placeholder="users.* or *"
|
||
required
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div class="row">
|
||
<span>Ops:</span>
|
||
<label><input type="checkbox" bind:checked={createDocsOps.create} /> create</label>
|
||
<label><input type="checkbox" bind:checked={createDocsOps.update} /> update</label>
|
||
<label><input type="checkbox" bind:checked={createDocsOps.delete} /> delete</label>
|
||
</div>
|
||
{#if createDocsError}<div class="error">{createDocsError}</div>{/if}
|
||
<div class="actions">
|
||
<button
|
||
type="submit"
|
||
disabled={creatingDocs || !createDocsScriptId || !createDocsCollectionGlob}
|
||
>
|
||
{creatingDocs ? 'Creating…' : 'Create docs:event trigger'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<h3>Create files:event trigger</h3>
|
||
<form class="create-form" onsubmit={submitCreateFiles}>
|
||
<div class="row">
|
||
<label>
|
||
<span>Target script</span>
|
||
<select bind:value={createFilesScriptId} 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>Collection glob</span>
|
||
<input
|
||
type="text"
|
||
bind:value={createFilesCollectionGlob}
|
||
placeholder="avatars.* or *"
|
||
required
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div class="row">
|
||
<span>Ops:</span>
|
||
<label><input type="checkbox" bind:checked={createFilesOps.create} /> create</label>
|
||
<label><input type="checkbox" bind:checked={createFilesOps.update} /> update</label>
|
||
<label><input type="checkbox" bind:checked={createFilesOps.delete} /> delete</label>
|
||
</div>
|
||
{#if createFilesError}<div class="error">{createFilesError}</div>{/if}
|
||
<div class="actions">
|
||
<button
|
||
type="submit"
|
||
disabled={creatingFiles || !createFilesScriptId || !createFilesCollectionGlob}
|
||
>
|
||
{creatingFiles ? 'Creating…' : 'Create files:event trigger'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<h3>Create dead_letter trigger</h3>
|
||
<p class="hint">
|
||
Fires when any other trigger or route dead-letters. All filter fields are optional;
|
||
leaving them blank routes every dead-letter to this script.
|
||
</p>
|
||
<form class="create-form" onsubmit={submitCreateDeadLetter}>
|
||
<div class="row">
|
||
<label>
|
||
<span>Target script</span>
|
||
<select bind:value={createDeadLetterScriptId} 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>Source filter (optional)</span>
|
||
<input
|
||
type="text"
|
||
bind:value={createDeadLetterSourceFilter}
|
||
placeholder="kv, docs, queue, pubsub, …"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div class="row">
|
||
<label class="grow">
|
||
<span>Trigger ID filter (optional)</span>
|
||
<input type="text" bind:value={createDeadLetterTriggerIdFilter} />
|
||
</label>
|
||
<label class="grow">
|
||
<span>Script ID filter (optional)</span>
|
||
<input type="text" bind:value={createDeadLetterScriptIdFilter} />
|
||
</label>
|
||
</div>
|
||
{#if createDeadLetterError}<div class="error">{createDeadLetterError}</div>{/if}
|
||
<div class="actions">
|
||
<button type="submit" disabled={creatingDeadLetter || !createDeadLetterScriptId}>
|
||
{creatingDeadLetter ? 'Creating…' : 'Create dead_letter 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.enabled}
|
||
<!-- F-U-006: surface the trigger.enabled flag. No PATCH endpoint
|
||
yet, so this is read-only; toggle UI tracked for a follow-up. -->
|
||
<span class="muted small" title="Trigger is disabled — events are not dispatched.">• disabled</span>
|
||
{/if}
|
||
{#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>
|
||
{:else if t.details.kind === 'queue'}
|
||
<code>{t.details.queue_name}</code>
|
||
<span class="muted">— vt {t.details.visibility_timeout_secs}s</span>
|
||
<span class="muted small">
|
||
last fired: {t.details.last_fired_at ?? 'never'}
|
||
</span>
|
||
{/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/<name></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 & 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, you’ll 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>
|