mirror of
https://github.com/saymrwulf/puncture.git
synced 2026-05-22 22:01:18 +00:00
548 lines
23 KiB
HTML
548 lines
23 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Puncture Go</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f4efe5;
|
|
--card: #fffdf8;
|
|
--ink: #172126;
|
|
--muted: #5d6a70;
|
|
--line: #d8cfbe;
|
|
--teal: #0f766e;
|
|
--orange: #c2410c;
|
|
--danger: #8b1d1d;
|
|
--ok-soft: #ddf6ee;
|
|
--warn-soft: #ffeede;
|
|
--danger-soft: #ffe6e6;
|
|
--info-soft: #eef4ff;
|
|
--radius: 14px;
|
|
--sans: "Avenir Next", "Trebuchet MS", "Lucida Grande", sans-serif;
|
|
--mono: Menlo, Consolas, Monaco, monospace;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: var(--sans);
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(900px 420px at -8% -12%, #d8ece8 0%, transparent 60%),
|
|
radial-gradient(680px 340px at 110% 0%, #f8dcc7 0%, transparent 55%),
|
|
var(--bg);
|
|
}
|
|
.wrap { max-width: 1200px; margin: 0 auto; padding: 14px; }
|
|
.card {
|
|
background: var(--card);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
padding: 12px;
|
|
margin-bottom: 10px;
|
|
}
|
|
h1 { margin: 2px 0 8px; font-size: clamp(1.35rem, 4vw, 2rem); }
|
|
h2 { margin: 0 0 8px; font-size: 1.05rem; }
|
|
p { margin: 0 0 8px; }
|
|
.muted { color: var(--muted); }
|
|
.mono { font-family: var(--mono); font-size: 0.8rem; word-break: break-all; }
|
|
|
|
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
|
.stat { border: 1px solid var(--line); border-radius: 10px; padding: 7px; background: #fff; }
|
|
.stat .label { color: var(--muted); font-size: 0.74rem; }
|
|
.stat .value { font-size: 1.12rem; font-weight: 700; }
|
|
|
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
|
|
|
label { display: block; margin: 8px 0 4px; font-size: 0.82rem; font-weight: 700; }
|
|
input, select {
|
|
width: 100%; border: 1px solid var(--line); border-radius: 10px;
|
|
padding: 8px 9px; font: inherit; color: var(--ink); background: #fff;
|
|
}
|
|
|
|
.btn {
|
|
appearance: none; border: 0; border-radius: 10px; cursor: pointer;
|
|
padding: 9px 11px; font: inherit; font-weight: 700;
|
|
}
|
|
.btn-primary { background: var(--teal); color: #fff; }
|
|
.btn-warn { background: var(--orange); color: #fff; }
|
|
.btn-danger { background: #f8dcdc; color: var(--danger); border: 1px solid #e8bcbc; }
|
|
.btn-ghost { background: #fff; color: var(--ink); border: 1px solid var(--line); }
|
|
.btn-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 9px; }
|
|
|
|
.notice { border: 1px solid var(--line); border-radius: 10px; padding: 9px; font-weight: 700; }
|
|
.notice.success { background: var(--ok-soft); color: #0b4f49; }
|
|
.notice.warn { background: var(--warn-soft); color: #6f2d13; }
|
|
.notice.danger { background: var(--danger-soft); color: #7f1717; }
|
|
.notice.info { background: var(--info-soft); color: #1f4f7a; }
|
|
|
|
#tree_shell { overflow-x: auto; border: 1px solid var(--line); border-radius: 10px; padding: 8px; background: #fff; }
|
|
.tree-legend { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.chip { border-radius: 999px; padding: 4px 7px; font-size: 0.75rem; border: 1px solid var(--line); background: #fff; }
|
|
.chip.frontier { background: #d9f3ee; border-color: #b8e6db; color: #0b4f49; }
|
|
.chip.possible { background: #e8f7ec; border-color: #c8e9d1; color: #1d5c2f; }
|
|
.chip.derived { background: #fff0d0; border-color: #f0d7a2; color: #7b4d0a; }
|
|
.chip.blocked { background: #fee6e6; border-color: #efc2c2; color: #7c1d1d; }
|
|
.chip.removed { background: #f8d0d0; border-color: #e7abab; color: #8e1a1a; }
|
|
|
|
.table { width: 100%; border-collapse: collapse; }
|
|
.table td, .table th { border-bottom: 1px dashed #ece2cf; padding: 6px 4px; text-align: left; }
|
|
.table th { color: var(--muted); font-size: 0.76rem; }
|
|
|
|
.mapping { border: 1px solid #ece3d1; border-radius: 10px; padding: 8px; margin-top: 7px; background: #fffefb; }
|
|
.mapping.blocked { background: #ffe7e7; border-color: #eec2c2; color: #7b1c1c; }
|
|
.mapping.glow { border-color: #96dccc; background: #e9fffa; box-shadow: 0 0 0 2px rgba(30,154,132,0.18); }
|
|
|
|
.history { max-height: 260px; overflow: auto; }
|
|
.history-item { border-bottom: 1px solid #ece5d5; padding: 8px 0; }
|
|
.history-meta { color: var(--muted); font-size: 0.76rem; }
|
|
|
|
@media (max-width: 980px) {
|
|
.stats { grid-template-columns: 1fr 1fr; }
|
|
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 580px) {
|
|
.stats { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="wrap">
|
|
<section class="card">
|
|
<h1>Puncture Go: macOS-primary forward secrecy</h1>
|
|
<p class="muted">Go implementation with tree-frontier visualization and local encryption/decryption workflow.</p>
|
|
<div id="top_notice" class="notice info">Loading state...</div>
|
|
<div class="stats" style="margin-top:8px">
|
|
<div class="stat"><div class="label">Active Nodes</div><div id="s_active_nodes" class="value">0</div></div>
|
|
<div class="stat"><div class="label">Puncture Events</div><div id="s_punctures" class="value">0</div></div>
|
|
<div class="stat"><div class="label">Cipher Mappings</div><div id="s_mappings" class="value">0</div></div>
|
|
<div class="stat"><div class="label">Blocked Mappings</div><div id="s_blocked" class="value">0</div></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Tree/Subtree Visualization</h2>
|
|
<p class="muted">Frontier roots are highlighted. Removed prior frontier after puncture is red. Impossible future subtrees are blocked.</p>
|
|
<div class="stats" style="margin-bottom:8px">
|
|
<div class="stat"><div class="label">Visible Frontier</div><div id="t_frontier" class="value">0</div></div>
|
|
<div class="stat"><div class="label">Blocked Nodes</div><div id="t_blocked" class="value">0</div></div>
|
|
<div class="stat"><div class="label">Removed Frontier</div><div id="t_removed" class="value">0</div></div>
|
|
<div class="stat"><div class="label">Projection Depth</div><div id="t_depth" class="value">0</div></div>
|
|
</div>
|
|
<div id="tree_shell"></div>
|
|
<div class="tree-legend">
|
|
<span class="chip frontier">Current frontier</span>
|
|
<span class="chip possible">Future derivable</span>
|
|
<span class="chip derived">Already derived branch</span>
|
|
<span class="chip blocked">Future impossible</span>
|
|
<span class="chip removed">Deleted frontier (last puncture)</span>
|
|
</div>
|
|
<p id="last_puncture_label" class="muted" style="margin-top:7px"></p>
|
|
</section>
|
|
|
|
<section class="grid-2">
|
|
<article class="card">
|
|
<h2>Derive / Puncture</h2>
|
|
<label for="provider_id">Provider ID</label>
|
|
<input id="provider_id" type="number" min="0" max="127" value="42" />
|
|
<label for="file_time_id">File/Time ID</label>
|
|
<input id="file_time_id" type="number" min="0" max="33554431" value="123456" />
|
|
<label for="purpose">Purpose / Description</label>
|
|
<input id="purpose" type="text" value="Demo key" maxlength="120" />
|
|
<div class="btn-row">
|
|
<button id="derive_btn" class="btn btn-primary">Derive Key</button>
|
|
<button id="puncture_btn" class="btn btn-warn">Puncture Tag</button>
|
|
<button id="reset_btn" class="btn btn-danger">Reset Lab</button>
|
|
</div>
|
|
<div style="margin-top:10px">
|
|
<div class="muted">Latest action</div>
|
|
<div id="last_action_title" style="font-weight:700"></div>
|
|
<div id="last_action_body"></div>
|
|
<div id="last_key_hex" class="mono" style="margin-top:6px"></div>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<h2>Providers</h2>
|
|
<label for="p_add_id">Add Provider ID</label>
|
|
<input id="p_add_id" type="number" min="0" max="127" value="99" />
|
|
<label for="p_add_name">Name</label>
|
|
<input id="p_add_name" type="text" value="New Provider" />
|
|
<label for="p_add_desc">Description</label>
|
|
<input id="p_add_desc" type="text" value="" />
|
|
<div class="btn-row">
|
|
<button id="p_add_btn" class="btn btn-primary">Add Provider</button>
|
|
</div>
|
|
<table class="table" style="margin-top:8px">
|
|
<thead><tr><th>ID</th><th>Name</th><th>Prefix</th><th>Delete</th></tr></thead>
|
|
<tbody id="providers_body"></tbody>
|
|
</table>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="grid-2">
|
|
<article class="card">
|
|
<h2>Assets: Upload -> Encrypt</h2>
|
|
<label for="upload_files">Files</label>
|
|
<input id="upload_files" type="file" multiple />
|
|
<label for="target_subdir">Target Subdir</label>
|
|
<input id="target_subdir" type="text" placeholder="optional/subdir" />
|
|
<div class="btn-row">
|
|
<button id="upload_btn" class="btn btn-ghost">Upload</button>
|
|
<button id="select_all_btn" class="btn btn-ghost">Select all</button>
|
|
<button id="clear_sel_btn" class="btn btn-ghost">Clear selection</button>
|
|
</div>
|
|
|
|
<label for="combo_quick">Quick key combo</label>
|
|
<select id="combo_quick"><option value="">Manual selection</option></select>
|
|
|
|
<label for="enc_provider_id">Encrypt with Provider ID</label>
|
|
<input id="enc_provider_id" type="number" min="0" max="127" value="42" />
|
|
<label for="enc_file_time_id">Encrypt with File/Time ID</label>
|
|
<input id="enc_file_time_id" type="number" min="0" max="33554431" value="123456" />
|
|
<label for="enc_purpose">Purpose</label>
|
|
<input id="enc_purpose" type="text" value="asset encryption" />
|
|
<div class="btn-row">
|
|
<button id="encrypt_btn" class="btn btn-primary">Encrypt Selected</button>
|
|
</div>
|
|
|
|
<table class="table" style="margin-top:9px">
|
|
<thead><tr><th></th><th>File</th><th>State</th><th>Meta</th></tr></thead>
|
|
<tbody id="files_body"></tbody>
|
|
</table>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<h2>Ciphertext Mappings + Decrypt</h2>
|
|
<div id="mappings_list"></div>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>History</h2>
|
|
<div id="history_box" class="history"></div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
let appState = null;
|
|
let selected = new Set();
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
|
|
function showNotice(tone, message) {
|
|
const n = el('top_notice');
|
|
n.className = 'notice ' + tone;
|
|
n.textContent = message;
|
|
}
|
|
|
|
async function api(path, method='GET', body=null, isForm=false) {
|
|
const opts = { method };
|
|
if (body !== null) {
|
|
if (isForm) {
|
|
opts.body = body;
|
|
} else {
|
|
opts.headers = { 'Content-Type': 'application/json' };
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
}
|
|
const resp = await fetch(path, opts);
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.ok) {
|
|
throw new Error(data.error || ('request failed: ' + resp.status));
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function renderStats(state) {
|
|
el('s_active_nodes').textContent = String(state.active_nodes || 0);
|
|
el('s_punctures').textContent = String((state.puncture_log || []).length);
|
|
el('s_mappings').textContent = String((state.assets && state.assets.mapping_count) || 0);
|
|
el('s_blocked').textContent = String((state.assets && state.assets.blocked_count) || 0);
|
|
}
|
|
|
|
function renderTree(state) {
|
|
const t = state.tree_viz || {};
|
|
el('t_frontier').textContent = String(t.current_frontier_count || 0);
|
|
el('t_blocked').textContent = String(t.blocked_count || 0);
|
|
el('t_removed').textContent = String(t.removed_count || 0);
|
|
el('t_depth').textContent = String(t.depth || 0);
|
|
el('tree_shell').innerHTML = t.svg || '<p class="muted">No tree projection available.</p>';
|
|
if (t.last_puncture) {
|
|
el('last_puncture_label').textContent = 'Last puncture (' + t.last_puncture.time + '): ' + t.last_puncture.target_kind + ' ' + t.last_puncture.target;
|
|
} else {
|
|
el('last_puncture_label').textContent = 'No puncture yet. Root frontier covers full derivation space.';
|
|
}
|
|
}
|
|
|
|
function renderLastAction(state) {
|
|
const a = state.last_action || {};
|
|
el('last_action_title').textContent = a.title || '';
|
|
el('last_action_body').textContent = a.body || '';
|
|
el('last_key_hex').textContent = a.key_hex ? ('key: ' + a.key_hex) : '';
|
|
}
|
|
|
|
function renderProviders(state) {
|
|
const body = el('providers_body');
|
|
body.innerHTML = '';
|
|
(state.providers || []).forEach((p) => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = '<td>' + p.provider_id + '</td><td>' + p.name + '</td><td class="mono">' + p.prefix + '</td>';
|
|
const td = document.createElement('td');
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'btn btn-danger';
|
|
btn.textContent = 'Delete+Puncture';
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm('Delete provider ' + p.provider_id + ' and puncture all keys?')) return;
|
|
try {
|
|
const out = await api('/api/providers/delete', 'POST', { provider_id: Number(p.provider_id) });
|
|
updateState(out.state);
|
|
showNotice('warn', 'Provider deleted and subtree punctured.');
|
|
} catch (err) {
|
|
showNotice('danger', String(err));
|
|
}
|
|
});
|
|
td.appendChild(btn);
|
|
tr.appendChild(td);
|
|
body.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function renderWorkflow(state) {
|
|
const w = state.workflow || {};
|
|
const files = w.files || [];
|
|
const combos = w.key_combo_options || [];
|
|
const body = el('files_body');
|
|
body.innerHTML = '';
|
|
|
|
const relset = new Set(files.map((f) => f.relpath));
|
|
selected = new Set([...selected].filter((p) => relset.has(p)));
|
|
|
|
files.forEach((f) => {
|
|
const tr = document.createElement('tr');
|
|
const c = document.createElement('input');
|
|
c.type = 'checkbox';
|
|
c.checked = selected.has(f.relpath);
|
|
c.addEventListener('change', () => {
|
|
if (c.checked) selected.add(f.relpath);
|
|
else selected.delete(f.relpath);
|
|
});
|
|
const td0 = document.createElement('td'); td0.appendChild(c);
|
|
const td1 = document.createElement('td'); td1.className = 'mono'; td1.textContent = f.relpath;
|
|
const td2 = document.createElement('td'); td2.textContent = f.lifecycle_label;
|
|
const td3 = document.createElement('td'); td3.textContent = f.size_label + ' · ' + f.modified_at;
|
|
tr.appendChild(td0); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
|
body.appendChild(tr);
|
|
});
|
|
|
|
const comboSel = el('combo_quick');
|
|
comboSel.innerHTML = '<option value="">Manual selection</option>';
|
|
combos.forEach((c) => {
|
|
const o = document.createElement('option');
|
|
o.value = String(c.provider_id) + '|' + String(c.file_time_id);
|
|
o.textContent = c.label;
|
|
comboSel.appendChild(o);
|
|
});
|
|
comboSel.onchange = () => {
|
|
if (!comboSel.value) return;
|
|
const parts = comboSel.value.split('|');
|
|
if (parts.length !== 2) return;
|
|
el('enc_provider_id').value = parts[0];
|
|
el('enc_file_time_id').value = parts[1];
|
|
};
|
|
}
|
|
|
|
function renderMappings(state) {
|
|
const wrap = el('mappings_list');
|
|
wrap.innerHTML = '';
|
|
const files = (state.assets && state.assets.asset_files) || [];
|
|
if (files.length === 0) {
|
|
wrap.innerHTML = '<p class="muted">No ciphertext mappings yet.</p>';
|
|
return;
|
|
}
|
|
files.forEach((f) => {
|
|
const box = document.createElement('div');
|
|
box.className = 'mapping';
|
|
box.innerHTML = '<strong>' + f.plaintext_relpath + '</strong><div class="muted">Mappings: ' + f.mapping_count + ' | Blocked: ' + f.blocked_count + '</div>';
|
|
(f.mappings || []).forEach((m) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'mapping' + (m.show_red ? ' blocked' : '') + (m.show_glow ? ' glow' : '');
|
|
const head = document.createElement('div');
|
|
head.innerHTML = '<strong>Provider ' + m.provider_id + ' | Key ' + m.file_time_id + '</strong> <span class="muted">' + (m.is_accessible ? 'decryptable' : 'blocked') + '</span>';
|
|
const c = document.createElement('div');
|
|
c.className = 'mono';
|
|
c.textContent = 'cipher: ' + m.ciphertext_relpath;
|
|
const d = document.createElement('div');
|
|
d.className = 'muted';
|
|
d.textContent = m.last_decrypted_relpath ? ('last decrypted: ' + m.last_decrypted_relpath + ' (' + (m.last_decrypted_at || 'time unknown') + ')') : 'not decrypted yet';
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn btn-ghost';
|
|
btn.textContent = m.is_accessible ? 'Decrypt To Filesystem' : 'Blocked (punctured key)';
|
|
btn.disabled = !m.is_accessible;
|
|
btn.addEventListener('click', async () => {
|
|
try {
|
|
const out = await api('/api/assets/decrypt', 'POST', { record_ids: [Number(m.record_id)] });
|
|
updateState(out.state);
|
|
showNotice(out.errors && out.errors.length ? 'warn' : 'success', 'Decryption finished.');
|
|
} catch (err) {
|
|
showNotice('danger', String(err));
|
|
}
|
|
});
|
|
row.appendChild(head); row.appendChild(c); row.appendChild(d); row.appendChild(btn);
|
|
box.appendChild(row);
|
|
});
|
|
wrap.appendChild(box);
|
|
});
|
|
}
|
|
|
|
function renderHistory(state) {
|
|
const box = el('history_box');
|
|
box.innerHTML = '';
|
|
const rows = state.history || [];
|
|
if (rows.length === 0) {
|
|
box.innerHTML = '<p class="muted">No actions yet.</p>';
|
|
return;
|
|
}
|
|
rows.forEach((h) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'history-item';
|
|
item.innerHTML = '<div class="history-meta">' + h.time + ' | ' + h.action + ' | ' + h.status + '</div><div>' + h.summary + '</div>' + (h.path ? ('<div class="mono muted">' + h.path + '</div>') : '');
|
|
box.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function updateState(state) {
|
|
appState = state;
|
|
renderStats(state);
|
|
renderTree(state);
|
|
renderLastAction(state);
|
|
renderProviders(state);
|
|
renderWorkflow(state);
|
|
renderMappings(state);
|
|
renderHistory(state);
|
|
}
|
|
|
|
async function refresh() {
|
|
const out = await api('/api/state');
|
|
updateState(out.state);
|
|
}
|
|
|
|
function wireActions() {
|
|
el('derive_btn').addEventListener('click', async () => {
|
|
try {
|
|
const out = await api('/api/derive', 'POST', {
|
|
provider_id: Number(el('provider_id').value),
|
|
file_time_id: Number(el('file_time_id').value),
|
|
purpose: el('purpose').value || ''
|
|
});
|
|
updateState(out.state);
|
|
showNotice('success', 'Derive completed.');
|
|
} catch (err) {
|
|
showNotice('danger', String(err));
|
|
}
|
|
});
|
|
|
|
el('puncture_btn').addEventListener('click', async () => {
|
|
try {
|
|
const out = await api('/api/puncture', 'POST', {
|
|
provider_id: Number(el('provider_id').value),
|
|
file_time_id: Number(el('file_time_id').value)
|
|
});
|
|
updateState(out.state);
|
|
showNotice('warn', 'Puncture processed.');
|
|
} catch (err) {
|
|
showNotice('danger', String(err));
|
|
}
|
|
});
|
|
|
|
el('reset_btn').addEventListener('click', async () => {
|
|
if (!confirm('Reset lab and destroy current state?')) return;
|
|
try {
|
|
const out = await api('/api/reset', 'POST');
|
|
selected.clear();
|
|
updateState(out.state);
|
|
showNotice('info', 'Lab reset completed.');
|
|
} catch (err) {
|
|
showNotice('danger', String(err));
|
|
}
|
|
});
|
|
|
|
el('p_add_btn').addEventListener('click', async () => {
|
|
try {
|
|
const out = await api('/api/providers/add', 'POST', {
|
|
provider_id: Number(el('p_add_id').value),
|
|
name: el('p_add_name').value,
|
|
description: el('p_add_desc').value
|
|
});
|
|
updateState(out.state);
|
|
showNotice('success', 'Provider added.');
|
|
} catch (err) {
|
|
showNotice('danger', String(err));
|
|
}
|
|
});
|
|
|
|
el('upload_btn').addEventListener('click', async () => {
|
|
const files = el('upload_files').files;
|
|
if (!files || files.length === 0) {
|
|
showNotice('warn', 'Select at least one file for upload.');
|
|
return;
|
|
}
|
|
const form = new FormData();
|
|
for (const f of files) form.append('files', f);
|
|
form.append('target_subdir', el('target_subdir').value || '');
|
|
try {
|
|
const out = await api('/api/assets/upload', 'POST', form, true);
|
|
(out.uploaded || []).forEach((p) => selected.add(p));
|
|
el('upload_files').value = '';
|
|
updateState(out.state);
|
|
showNotice('success', 'Upload completed.');
|
|
} catch (err) {
|
|
showNotice('danger', String(err));
|
|
}
|
|
});
|
|
|
|
el('select_all_btn').addEventListener('click', () => {
|
|
const files = ((appState || {}).workflow || {}).files || [];
|
|
files.forEach((f) => selected.add(f.relpath));
|
|
renderWorkflow(appState);
|
|
});
|
|
|
|
el('clear_sel_btn').addEventListener('click', () => {
|
|
selected.clear();
|
|
renderWorkflow(appState);
|
|
});
|
|
|
|
el('encrypt_btn').addEventListener('click', async () => {
|
|
if (selected.size === 0) {
|
|
showNotice('warn', 'Select at least one cleartext file before encryption.');
|
|
return;
|
|
}
|
|
try {
|
|
const out = await api('/api/assets/encrypt', 'POST', {
|
|
plaintext_relpaths: Array.from(selected),
|
|
provider_id: Number(el('enc_provider_id').value),
|
|
file_time_id: Number(el('enc_file_time_id').value),
|
|
purpose: el('enc_purpose').value || ''
|
|
});
|
|
selected.clear();
|
|
updateState(out.state);
|
|
showNotice((out.errors && out.errors.length) ? 'warn' : 'success', 'Encryption completed.');
|
|
} catch (err) {
|
|
showNotice('danger', String(err));
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
wireActions();
|
|
try {
|
|
await refresh();
|
|
showNotice('info', 'State loaded.');
|
|
} catch (err) {
|
|
showNotice('danger', String(err));
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|