import { For, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"; import { SecretsService, type ProjectSecret, } from "@primora/api-client"; import { Modal } from "../components/Modal"; import { Input, Select, Textarea } from "../components/Input"; import { IconPlus, IconTrash, IconCopy, IconRefresh, IconKey } from "../components/Icons"; import { errorMessage } from "../lib/api"; interface VaultPageProps { projectID?: string; canManage: boolean; demoMode: boolean; } const err = (e: unknown) => errorMessage(e, String(e)); const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—"); const DEMO_SECRETS: ProjectSecret[] = [ { id: "d1", name: "STRIPE_SECRET", url: "https://dashboard.stripe.com", notes: "prod", created_at: "", updated_at: "2026-09-18T10:00:00Z" }, { id: "d2", name: "SLACK_WEBHOOK", url: "https://hooks.slack.com", notes: "alerts channel", created_at: "", updated_at: "2026-09-10T08:00:00Z" }, ]; // Inside the Tauri shell, __TAURI__ is injected into every webview — the same // IPC commands the dedicated vault window uses. Absent in a plain browser. const tauri = () => (window as unknown as { __TAURI__?: { core: { invoke: (cmd: string, args?: Record) => Promise } }; }).__TAURI__; interface LocalSecret { name: string; url: string | null; notes: string | null; updated_at: string; } interface LocalStatus { exists: boolean; unlocked: boolean; expires_at: number | null; secrets: number | null; path: string; } export function VaultPage(props: VaultPageProps) { const [secrets, setSecrets] = createSignal([]); const [message, setMessage] = createSignal(""); const [error, setError] = createSignal(""); const [busy, setBusy] = createSignal(); const [showCreate, setShowCreate] = createSignal(false); const [showImport, setShowImport] = createSignal(false); const [form, setForm] = createSignal({ name: "", value: "", url: "", notes: "" }); const [importText, setImportText] = createSignal(""); const [localStatus, setLocalStatus] = createSignal(); const [localSecrets, setLocalSecrets] = createSignal([]); const [localErr, setLocalErr] = createSignal(""); const [localPw, setLocalPw] = createSignal(""); const [localTtl, setLocalTtl] = createSignal("900"); const [now, setNow] = createSignal(Math.floor(Date.now() / 1000)); const timer = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000); onCleanup(() => clearInterval(timer)); const refresh = async () => { if (!props.projectID) return; if (props.demoMode) { setSecrets(DEMO_SECRETS); return; } try { const res = await SecretsService.listProjectSecrets({ projectId: props.projectID }); setSecrets(res.items); } catch (e) { setError(err(e)); } }; const refreshLocal = async () => { const t = tauri(); if (!t) return; try { const st = (await t.core.invoke("vault_status")) as LocalStatus; setLocalStatus(st); setLocalSecrets(st.unlocked ? ((await t.core.invoke("vault_secrets")) as LocalSecret[]) : []); } catch (e) { setLocalErr(err(e)); } }; onMount(() => { void refresh(); void refreshLocal(); }); createEffect(() => void refresh()); const run = async (key: string, task: () => Promise, done?: string) => { setBusy(key); setError(""); setMessage(""); try { await task(); if (done) setMessage(done); await refresh(); } catch (e) { setError(err(e)); } finally { setBusy(undefined); } }; const runLocal = async (task: () => Promise) => { setLocalErr(""); try { await task(); } catch (e) { setLocalErr(err(e)); } finally { await refreshLocal(); } }; const copy = async (text: string, label: string) => { try { await navigator.clipboard.writeText(text); setMessage(`${label} copied to clipboard`); } catch { setError("Clipboard unavailable — check browser permissions."); } }; const reveal = (name: string) => run(`reveal-${name}`, async () => { if (props.demoMode) { await copy(`demo-${name.toLowerCase()}`, name); return; } const res = await SecretsService.revealProjectSecret({ projectId: props.projectID!, name }); await copy(res.value, name); }); const revealLocal = (name: string) => runLocal(async () => { const value = (await tauri()!.core.invoke("vault_reveal", { name })) as string; await copy(value.trimEnd(), name); }); const createSecret = (e: Event) => { e.preventDefault(); const f = form(); void run("create", async () => { if (props.demoMode) { setSecrets([...secrets(), { id: `d${Date.now()}`, name: f.name, url: f.url, notes: f.notes, created_at: "", updated_at: new Date().toISOString() }]); return; } await SecretsService.setProjectSecret({ projectId: props.projectID!, name: f.name.trim(), requestBody: { value: f.value, url: f.url.trim() || undefined, notes: f.notes.trim() || undefined }, }); setShowCreate(false); setForm({ name: "", value: "", url: "", notes: "" }); }, `Secret ${f.name} stored`); }; const removeSecret = (name: string) => run(`del-${name}`, async () => { if (props.demoMode) { setSecrets(secrets().filter((s) => s.name !== name)); return; } await SecretsService.deleteProjectSecret({ projectId: props.projectID!, name }); }, `Secret ${name} deleted`); const importEnv = (e: Event) => { e.preventDefault(); const pairs = importText() .split("\n") .map((l) => l.trim()) .filter((l) => l && !l.startsWith("#") && l.includes("=")) .map((l) => { const i = l.indexOf("="); return [l.slice(0, i).trim().replace(/^export\s+/, ""), l.slice(i + 1).trim().replace(/^["']|["']$/g, "")] as const; }) .filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k)); if (!pairs.length) { setError("No valid KEY=value lines found."); return; } void run("import", async () => { for (const [name, value] of pairs) { await SecretsService.setProjectSecret({ projectId: props.projectID!, name, requestBody: { value } }); } setShowImport(false); setImportText(""); }, `Imported ${pairs.length} secret${pairs.length === 1 ? "" : "s"}`); }; const localRemaining = () => { const st = localStatus(); return st?.expires_at ? Math.max(0, st.expires_at - now()) : 0; }; return (
{message()}
{error()}
Project secrets
Stored AES-256-GCM encrypted in this deployment. Copy goes to your clipboard and is audit-logged.
0} fallback={

No secrets yet — create one or import a .env file.

} >
{(s) => ( )}
Name URL Notes Updated Actions
{s.name} {s.url ? {s.url.replace(/^https?:\/\//, "")} : "—"} {s.notes || "—"} {fmtTime(s.updated_at)}
This machine
Local vault on this device — the same one the CLI manages. These secrets never touch the server.
unlocked — {Math.floor(localRemaining() / 60)}m {String(localRemaining() % 60).padStart(2, "0")}s
{localErr()}
No vault on this device — create one with primora vault init or from the tray's Secrets Vault window.

} >
{ e.preventDefault(); void runLocal(() => tauri()!.core.invoke("vault_unlock", { password: localPw(), ttl: Number(localTtl()) })); setLocalPw(""); }} >
setLocalPw(e.currentTarget.value)} />
} > 0} fallback={

Local vault is empty — manage it from the tray's Secrets Vault window or the CLI.

} >
{(s) => ( )}
NameURLNotesActions
{s.name} {s.url ?? "—"} {s.notes ?? "—"}

Injection and the credential broker stay in the CLI: primora inject / primora agent.

setShowCreate(false)} title="New secret" size="md">
setForm({ ...form(), name: e.currentTarget.value })} required /> setForm({ ...form(), value: e.currentTarget.value })} required /> setForm({ ...form(), url: e.currentTarget.value })} /> setForm({ ...form(), notes: e.currentTarget.value })} />
setShowImport(false)} title="Import .env" size="md">