mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: encrypted secrets vault across CLI, desktop, dashboard, and backend
Local vault (apps/cli): clean-room PRMVLT01 format — Argon2id → XChaCha20-Poly1305, AEAD-bound header, atomic writes. vault:* / secrets:* / inject (primora:// refs) / agent credential broker (dummy tokens in the child, real secret on the wire) / TTL sessions / stack:* operator group. Project vault (backend): core.project_secrets, AES-256-GCM via the platform encryptor. Metadata-only list, audit-logged reveal, and secret://NAME refs resolved at job delivery — refs stored, values never. Surfaces: dashboard Vault page (+ local-vault card under the Tauri shell), desktop vault window via IPC to the primora binary, secrets:* --remote CLI mode, metadata/write-only MCP tools so agents consume secrets via secret:// refs without holding plaintext. Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
31bc23386b
commit
585bb04ab2
52 files changed
+3929
-142
No files matched your search
@@ -28,4 +28,19 @@ npm run bundle --workspace @primora/desktop # release bundles → src-tauri/ta
|
||||
to be a Primora deployment, use the tray's Change Server.
|
||||
- macOS blocks plain-HTTP loads to non-localhost hosts (ATS). Use HTTPS, or
|
||||
`http://localhost` for a local deployment.
|
||||
|
||||
## Secrets Vault
|
||||
|
||||
The tray menu's **Secrets Vault…** opens a local window (`ui/vault.html`) for
|
||||
the CLI's encrypted vault — init, timed unlock/lock, list with copy-to-clipboard,
|
||||
add/delete, `.env` import. Values are never displayed; copy goes straight to the
|
||||
clipboard.
|
||||
|
||||
The desktop app does not reimplement the vault: IPC commands shell out to the
|
||||
`primora` binary on PATH (`PRIMORA_CLI` overrides the binary path). One vault
|
||||
file, one crypto implementation — terminal and desktop always agree. Requires
|
||||
`npm i -g @primora/cli`; without it the window shows install instructions.
|
||||
|
||||
Injection and the credential broker stay CLI-only (`primora inject`,
|
||||
`primora agent`).
|
||||
- Deep links / push notifications: deliberately omitted — nothing needs them yet.
|
||||
@@ -2,6 +2,6 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "vault"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager, WindowEvent,
|
||||
AppHandle, Listener, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
|
||||
/// Persisted shell state — one field, the deployment to load.
|
||||
@@ -67,24 +70,163 @@ fn show_main(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_vault(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("vault") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return;
|
||||
}
|
||||
let _ = WebviewWindowBuilder::new(app, "vault", WebviewUrl::App("vault.html".into()))
|
||||
.title("Primora — Secrets Vault")
|
||||
.inner_size(780.0, 620.0)
|
||||
.min_inner_size(560.0, 420.0)
|
||||
.center()
|
||||
.build();
|
||||
}
|
||||
|
||||
/// The vault lives in the CLI — the desktop proxies the `primora` binary so
|
||||
/// both surfaces operate on the same vault file, one crypto implementation.
|
||||
/// Passwords travel via env, secret values via stdin; never via argv.
|
||||
fn run_cli(args: &[&str], stdin_data: Option<&str>, password: Option<&str>) -> Result<String, String> {
|
||||
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.args(args)
|
||||
.stdin(if stdin_data.is_some() { Stdio::piped() } else { Stdio::null() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(pw) = password {
|
||||
cmd.env("PRIMORA_VAULT_PASSWORD", pw);
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|_| {
|
||||
"primora CLI not found on PATH — install it: npm i -g @primora/cli".to_string()
|
||||
})?;
|
||||
if let Some(data) = stdin_data {
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.ok_or("failed to open child stdin")?
|
||||
.write_all(data.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_probe() -> bool {
|
||||
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
|
||||
Command::new(bin)
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_status() -> Result<Value, String> {
|
||||
let out = run_cli(&["vault", "status", "--json"], None, None)?;
|
||||
serde_json::from_str(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_secrets() -> Result<Value, String> {
|
||||
let out = run_cli(&["secrets", "list", "--json"], None, None)?;
|
||||
serde_json::from_str(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_reveal(name: String) -> Result<String, String> {
|
||||
run_cli(&["secrets", "get", &name], None, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_init(password: String) -> Result<(), String> {
|
||||
run_cli(&["vault", "init"], None, Some(&password)).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_unlock(password: String, ttl: u32) -> Result<(), String> {
|
||||
let ttl = ttl.clamp(60, 86400).to_string();
|
||||
run_cli(&["vault", "unlock", "--ttl", &ttl], None, Some(&password)).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_lock() -> Result<(), String> {
|
||||
run_cli(&["vault", "lock"], None, None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_set(
|
||||
name: String,
|
||||
value: String,
|
||||
url: Option<String>,
|
||||
notes: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut args = vec!["secrets", "set", name.as_str()];
|
||||
if let Some(u) = url.as_deref().filter(|s| !s.is_empty()) {
|
||||
args.extend(["--url", u]);
|
||||
}
|
||||
if let Some(n) = notes.as_deref().filter(|s| !s.is_empty()) {
|
||||
args.extend(["--notes", n]);
|
||||
}
|
||||
run_cli(&args, Some(&value), None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_rm(name: String) -> Result<(), String> {
|
||||
run_cli(&["secrets", "rm", &name, "--yes"], None, None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_import(path: String, overwrite: bool) -> Result<String, String> {
|
||||
let mut args = vec!["secrets", "import", path.as_str()];
|
||||
if overwrite {
|
||||
args.push("--overwrite");
|
||||
}
|
||||
run_cli(&args, None, None)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
show_main(app);
|
||||
}))
|
||||
.invoke_handler(tauri::generate_handler![get_server_url, set_server_url])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_server_url,
|
||||
set_server_url,
|
||||
vault_probe,
|
||||
vault_status,
|
||||
vault_secrets,
|
||||
vault_reveal,
|
||||
vault_init,
|
||||
vault_unlock,
|
||||
vault_lock,
|
||||
vault_set,
|
||||
vault_rm,
|
||||
vault_import,
|
||||
])
|
||||
.setup(|app| {
|
||||
let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?;
|
||||
let vault_item = MenuItem::with_id(app, "vault", "Secrets Vault…", true, None::<&str>)?;
|
||||
let change = MenuItem::with_id(app, "change_server", "Change Server…", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show, &change, &quit])?;
|
||||
let menu = Menu::with_items(app, &[&show, &vault_item, &change, &quit])?;
|
||||
|
||||
let mut tray = TrayIconBuilder::new()
|
||||
.menu(&menu)
|
||||
.tooltip("Primora")
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"show" => show_main(app),
|
||||
"vault" => open_vault(app),
|
||||
"change_server" => {
|
||||
// Clearing the URL and restarting lands back on the connect screen.
|
||||
let _ = save_config(app, &DesktopConfig { server_url: None });
|
||||
@@ -108,6 +250,10 @@ pub fn run() {
|
||||
}
|
||||
let _tray = tray.build(app)?;
|
||||
|
||||
// The connect screen links here — "open the local Secrets Vault".
|
||||
let handle = app.handle().clone();
|
||||
app.listen("open-vault", move |_| open_vault(&handle));
|
||||
|
||||
// Close hides to the tray instead of quitting — Quit is on the menu.
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let handle = window.clone();
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
</form>
|
||||
<p id="err" class="err" hidden></p>
|
||||
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
|
||||
<p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
@@ -51,6 +52,11 @@
|
||||
if (saved) location.replace(saved);
|
||||
}
|
||||
|
||||
document.getElementById("vault-link").addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
tauri?.event.emit("open-vault");
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
err.hidden = true;
|
||||
|
||||
@@ -99,3 +99,222 @@ button:disabled {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---------- secrets vault window ---------- */
|
||||
|
||||
body.vault {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #26292f;
|
||||
background: #101216;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar-sub {
|
||||
font-size: 0.72rem;
|
||||
color: #6d6d76;
|
||||
font-family: ui-monospace, monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #6d6d76;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
color: #34d399;
|
||||
border-color: #1e3a2f;
|
||||
}
|
||||
|
||||
.pill.warn {
|
||||
color: #9a9aa3;
|
||||
}
|
||||
|
||||
.view {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vcard {
|
||||
background: #14161b;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.vcard h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.vcard select {
|
||||
padding: 0.65rem 0.8rem;
|
||||
background: #0b0d11;
|
||||
border: 1px solid #2e3239;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.vtable th {
|
||||
text-align: left;
|
||||
color: #6d6d76;
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid #26292f;
|
||||
}
|
||||
|
||||
.vtable td {
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-bottom: 1px solid #1e2025;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vtable tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.dimcell {
|
||||
color: #9a9aa3;
|
||||
max-width: 12rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid #2e3239;
|
||||
color: #c9c9cf;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ghost:hover {
|
||||
border-color: #f4693a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ghost.sm {
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
border-color: #ef6b6b;
|
||||
color: #ef6b6b;
|
||||
}
|
||||
|
||||
.vform {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vform input {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vform input.grow {
|
||||
flex: 3 1 14rem;
|
||||
}
|
||||
|
||||
.vform button {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
.codeblock {
|
||||
background: #0b0d11;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #e7e7ea;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.vlink {
|
||||
color: #f4693a;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.vlink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: #6d6d76;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Primora — Secrets Vault</title>
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="vault">
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<img src="icon.svg" alt="" />
|
||||
<div>
|
||||
<h1>Secrets Vault</h1>
|
||||
<p id="path" class="bar-sub"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-right">
|
||||
<span id="pill" class="pill"></span>
|
||||
<button id="lock-btn" class="ghost" hidden>Lock</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="view" class="view">
|
||||
<p class="dim">Loading…</p>
|
||||
</main>
|
||||
<script type="module" src="vault.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,247 @@
|
||||
const tauri = window.__TAURI__;
|
||||
const view = document.getElementById("view");
|
||||
const pill = document.getElementById("pill");
|
||||
const lockBtn = document.getElementById("lock-btn");
|
||||
const pathEl = document.getElementById("path");
|
||||
|
||||
let countdownTimer = null;
|
||||
|
||||
const el = (tag, attrs = {}, ...children) => {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "class") node.className = v;
|
||||
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
|
||||
else if (v !== null && v !== undefined && v !== false) node.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) node.append(c);
|
||||
return node;
|
||||
};
|
||||
|
||||
const errBox = (msg) => el("p", { class: "err" }, msg);
|
||||
const dimNote = (msg) => el("p", { class: "hint" }, msg);
|
||||
|
||||
function setPill(state, expiresAt) {
|
||||
clearInterval(countdownTimer);
|
||||
pill.className = "pill";
|
||||
lockBtn.hidden = true;
|
||||
if (state === "unlocked" && expiresAt) {
|
||||
const tick = () => {
|
||||
const left = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
|
||||
const m = Math.floor(left / 60);
|
||||
const s = left % 60;
|
||||
pill.textContent = `● unlocked — ${m}m ${String(s).padStart(2, "0")}s`;
|
||||
if (!left) refresh();
|
||||
};
|
||||
pill.classList.add("ok");
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
lockBtn.hidden = false;
|
||||
} else {
|
||||
pill.textContent = state === "none" ? "no vault" : "● locked";
|
||||
if (state !== "none") pill.classList.add("warn");
|
||||
}
|
||||
}
|
||||
|
||||
function card(title, sub, ...children) {
|
||||
return el("section", { class: "vcard" },
|
||||
el("h2", {}, title),
|
||||
sub ? el("p", { class: "sub" }, sub) : "",
|
||||
...children);
|
||||
}
|
||||
|
||||
function busy(btn, label) {
|
||||
btn.disabled = true;
|
||||
btn.dataset.label = btn.textContent;
|
||||
btn.textContent = label;
|
||||
}
|
||||
const unbusy = (btn) => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label || btn.textContent;
|
||||
};
|
||||
|
||||
/* ---------- views ---------- */
|
||||
|
||||
function renderMissing() {
|
||||
setPill("none");
|
||||
pathEl.textContent = "";
|
||||
view.replaceChildren(card(
|
||||
"CLI required",
|
||||
"The vault lives in the primora CLI — the desktop app drives the same vault file, so terminal and desktop always agree.",
|
||||
el("pre", { class: "codeblock" }, "npm i -g @primora/cli\nprimora vault init"),
|
||||
dimNote("Reopen this window after installing.")
|
||||
));
|
||||
}
|
||||
|
||||
function renderInit() {
|
||||
setPill("none");
|
||||
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "new-password" });
|
||||
const pw2 = el("input", { type: "password", placeholder: "Confirm password", autocomplete: "new-password" });
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const btn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (pw.value.length < 8) { err.textContent = "Use at least 8 characters."; err.hidden = false; return; }
|
||||
if (pw.value !== pw2.value) { err.textContent = "Passwords do not match."; err.hidden = false; return; }
|
||||
busy(btn, "Creating…");
|
||||
try { await tauri.core.invoke("vault_init", { password: pw.value }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Create vault");
|
||||
view.replaceChildren(card(
|
||||
"Create your vault",
|
||||
"Local, encrypted, Argon2id + XChaCha20-Poly1305. The password is not recoverable — lose it and the contents are gone.",
|
||||
pw, pw2, btn, err
|
||||
));
|
||||
}
|
||||
|
||||
function renderLocked() {
|
||||
setPill("locked");
|
||||
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "current-password", autofocus: true });
|
||||
const ttl = el("select", {},
|
||||
el("option", { value: "900" }, "Unlock for 15 minutes"),
|
||||
el("option", { value: "3600" }, "Unlock for 1 hour"),
|
||||
el("option", { value: "28800" }, "Unlock for 8 hours"),
|
||||
el("option", { value: "86400" }, "Unlock for 24 hours"));
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const btn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
busy(btn, "Unlocking…");
|
||||
try { await tauri.core.invoke("vault_unlock", { password: pw.value, ttl: Number(ttl.value) }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Unlock");
|
||||
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") btn.click(); });
|
||||
view.replaceChildren(card(
|
||||
"Vault is locked",
|
||||
"Unlocks a timed session — agents and scripts can use secrets without the password until it expires.",
|
||||
pw, ttl, btn, err
|
||||
));
|
||||
}
|
||||
|
||||
function secretRow(s) {
|
||||
const copy = el("button", { class: "ghost sm", title: "Copy value" }, "copy");
|
||||
copy.addEventListener("click", async () => {
|
||||
try {
|
||||
const value = await tauri.core.invoke("vault_reveal", { name: s.name });
|
||||
await navigator.clipboard.writeText(value.trimEnd());
|
||||
copy.textContent = "copied";
|
||||
setTimeout(() => (copy.textContent = "copy"), 1200);
|
||||
} catch (e) { copy.textContent = "error"; setTimeout(() => (copy.textContent = "copy"), 1500); }
|
||||
});
|
||||
const del = el("button", { class: "ghost sm danger", title: "Delete" }, "delete");
|
||||
del.addEventListener("click", async () => {
|
||||
if (del.dataset.armed) {
|
||||
try { await tauri.core.invoke("vault_rm", { name: s.name }); } catch {}
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
del.dataset.armed = "1";
|
||||
del.textContent = "sure?";
|
||||
setTimeout(() => { delete del.dataset.armed; del.textContent = "delete"; }, 2500);
|
||||
});
|
||||
return el("tr", {},
|
||||
el("td", { class: "mono" }, s.name),
|
||||
el("td", {}, s.url ? el("span", {
|
||||
class: "vlink",
|
||||
title: `${s.url} — click to copy`,
|
||||
onclick: async () => { await navigator.clipboard.writeText(s.url).catch(() => {}); },
|
||||
}, s.url.replace(/^https?:\/\//, "").slice(0, 34)) : "—"),
|
||||
el("td", { class: "dimcell", title: s.notes ?? "" }, (s.notes ?? "—").slice(0, 34)),
|
||||
el("td", { class: "dimcell" }, (s.updated_at ?? "").slice(0, 10)),
|
||||
el("td", { class: "acts" }, copy, del));
|
||||
}
|
||||
|
||||
async function renderUnlocked(st) {
|
||||
setPill("unlocked", st.expires_at);
|
||||
const secrets = await tauri.core.invoke("vault_secrets").catch(() => []);
|
||||
|
||||
const table = secrets.length
|
||||
? el("table", { class: "vtable" },
|
||||
el("thead", {}, el("tr", {},
|
||||
el("th", {}, "name"), el("th", {}, "url"), el("th", {}, "notes"),
|
||||
el("th", {}, "updated"), el("th", {}, ""))),
|
||||
el("tbody", {}, ...secrets.map(secretRow)))
|
||||
: dimNote("Empty — add a secret below or import a .env file.");
|
||||
|
||||
// inline add form
|
||||
const name = el("input", { placeholder: "NAME", class: "mono", spellcheck: "false" });
|
||||
const value = el("input", { type: "password", placeholder: "value", autocomplete: "off" });
|
||||
const url = el("input", { placeholder: "url (optional)", inputmode: "url" });
|
||||
const notes = el("input", { placeholder: "notes (optional)" });
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const addBtn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name.value)) {
|
||||
err.textContent = "Name must be env-safe: [A-Za-z_][A-Za-z0-9_]*";
|
||||
err.hidden = false; return;
|
||||
}
|
||||
if (!value.value) { err.textContent = "Value required."; err.hidden = false; return; }
|
||||
busy(addBtn, "Encrypting…");
|
||||
try {
|
||||
await tauri.core.invoke("vault_set", {
|
||||
name: name.value, value: value.value, url: url.value || null, notes: notes.value || null,
|
||||
});
|
||||
} catch (e) { err.textContent = String(e); err.hidden = false; unbusy(addBtn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Add secret");
|
||||
|
||||
const importPath = el("input", { placeholder: "/path/to/.env", class: "mono grow" });
|
||||
const importBtn = el("button", {
|
||||
class: "ghost",
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (!importPath.value.trim()) return;
|
||||
busy(importBtn, "Importing…");
|
||||
try { await tauri.core.invoke("vault_import", { path: importPath.value.trim(), overwrite: false }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(importBtn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Import .env");
|
||||
|
||||
view.replaceChildren(
|
||||
el("section", { class: "vcard" },
|
||||
el("h2", {}, `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`),
|
||||
table,
|
||||
el("div", { class: "vform" }, name, value, url, notes, addBtn),
|
||||
el("div", { class: "vform" }, importPath, importBtn),
|
||||
err),
|
||||
el("p", { class: "hint" },
|
||||
"Values are never shown here — copy goes straight to the clipboard. " +
|
||||
"Injection and the credential broker stay in the CLI: primora inject / primora agent.")
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
|
||||
async function refresh() {
|
||||
if (!tauri) {
|
||||
view.replaceChildren(errBox("This page is meant to run inside the Primora desktop app."));
|
||||
return;
|
||||
}
|
||||
const ok = await tauri.core.invoke("vault_probe").catch(() => false);
|
||||
if (!ok) return renderMissing();
|
||||
const st = await tauri.core.invoke("vault_status").catch((e) => ({ error: String(e) }));
|
||||
if (st.error) { view.replaceChildren(errBox(st.error)); return; }
|
||||
pathEl.textContent = st.path ?? "";
|
||||
if (!st.exists) return renderInit();
|
||||
if (!st.unlocked) return renderLocked();
|
||||
renderUnlocked(st);
|
||||
}
|
||||
|
||||
lockBtn.addEventListener("click", async () => {
|
||||
await tauri.core.invoke("vault_lock").catch(() => {});
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Keep the pill/table honest if the session expires while the window sits open.
|
||||
setInterval(async () => {
|
||||
const st = await tauri?.core.invoke("vault_status").catch(() => null);
|
||||
if (st && !st.unlocked && !lockBtn.hidden) refresh();
|
||||
}, 30000);
|
||||
|
||||
refresh();
|
||||
Reference in new issue
Block a user