Files
Mako/internal/web/static/vault.js
T
VeillaxandClaude Sonnet 5.5 c08a8b8718
CI / test (push) Successful in 2m2s
Release / release (push) Successful in 2m52s
Add a passphrase-locked vault
A second notebook whose pages are encrypted with a key made from a passphrase plus the server key. It is unlocked into memory only (15 minutes idle, Lock, or sign-out), is not in search, lists or downloads of the ordinary notebook, and limits wrong guesses.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:40:35 -07:00

160 lines
8.7 KiB
JavaScript

'use strict';
// ---------- the vault: pages that open only with a passphrase ----------
// The server holds the vault's key only while it is unlocked, and forgets it when you lock it, sign out
// or leave it alone for a quarter of an hour. Its pages never show in the ordinary notebook's lists, search
// or download, and they cannot link to it.
const VAULT = '/api/vault';
function vaultView(arg) {
const root = h('div', { class: 'page vault' });
let ed = null, dead = false;
const view = { el: root, title: 'Vault', dispose() { dead = true; ed?.dispose?.(); } };
const lock = async () => {
await flushAll();
await api('POST', VAULT + '/lock');
location.hash = '#/vault';
show();
};
function passField(label, autocomplete) {
const input = h('input', { type: 'password', class: 'vault-pass', autocomplete, 'aria-label': label, placeholder: label, required: true });
return input;
}
// one form for creating and for unlocking
function gate({ title, text, fields, submit, label, extra }) {
const err = h('p', { class: 'err small', role: 'alert', hidden: true });
const form = h('form', { class: 'vault-form', onsubmit: async e => {
e.preventDefault();
err.hidden = true;
try { await submit(); }
catch (x) { err.textContent = x.message; err.hidden = false; fields[0].select(); }
} }, ...fields, err, h('button', { class: 'btn', type: 'submit' }, icon('lock', 16), label));
fill(root, h('div', { class: 'vault-gate' }, card('', h('div', { class: 'vault-icon' }, icon('lock', 22)), h('h1', { class: 'login-h' }, title),
h('p', { class: 'dim center-text' }, text), form, extra)));
setTimeout(() => fields[0].focus());
}
function create() {
const a = passField('Passphrase', 'new-password'), b = passField('Type it again', 'new-password');
gate({
title: 'Make your vault',
text: 'A place for pages that need more than a sign-in. They are locked with a passphrase, and Mako cannot read them until you unlock them.',
fields: [a, b], label: 'Create vault',
extra: h('p', { class: 'notice notice-warning small' }, icon('alert', 16), 'If you forget the passphrase, the pages in the vault cannot be recovered. Nobody can reset it, not even whoever runs Mako.'),
async submit() {
if (a.value !== b.value) throw new Error('The two passphrases are different.');
await api('POST', VAULT, { passphrase: a.value });
a.value = b.value = '';
show();
},
});
}
function unlock() {
const a = passField('Passphrase', 'current-password');
gate({
title: 'The vault is locked',
text: 'Type your passphrase to open it. It locks again when you leave it alone for a while, or when you sign out.',
fields: [a], label: 'Unlock',
async submit() {
try { await api('POST', VAULT + '/unlock', { passphrase: a.value }); }
catch (e) { throw new Error(e.data?.code === 'wrong_passphrase' ? 'That is not the passphrase. Try again.' : e.message); }
a.value = '';
show();
},
});
}
// the pages in the vault, or its trash
async function list(trash) {
fill(root, skeleton());
const r = await api('GET', VAULT + '/notes?limit=200&view=' + (trash ? 'trash' : 'all'));
if (dead) return;
const rows = r.notes.map(n => h('li', { class: 'vault-row' },
h('a', { class: 'vault-name', href: '#/vault/' + n.id }, icon('notes', 16), h('span', { class: 'clip1' }, n.name)),
h('span', { class: 'sub' }, fmtTime(n.updated)),
trash ? [
btn('Restore', 'restore', act(async () => { await api('POST', `${VAULT}/notes/${n.id}/restore`); list(true); })),
btn('Delete for good', 'trash', act(async () => {
if (!await confirmDialog({ title: 'Delete this page for good?', body: `"${n.name}" will be deleted and cannot be brought back.`, confirm: 'Delete page' })) return;
await api('DELETE', `${VAULT}/notes/${n.id}?purge=1`); list(true);
}), 'ghost danger')] : null));
fill(root,
h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, trash ? 'Vault trash' : 'Vault'),
h('p', {}, trash ? 'Deleted vault pages wait here until you delete them for good.' : 'Unlocked. These pages are not in search, the sidebar or your download.')),
h('div', { class: 'actions' },
trash ? btn('Back to the vault', 'prev', () => list(false)) : [btn('Trash', 'trash', () => list(true)), btn('Lock', 'lock', act(lock))],
trash ? null : h('button', { class: 'btn', onclick: () => { location.hash = '#/vault/new'; } }, icon('add', 16), 'New page'))),
rows.length ? h('ul', { class: 'vault-list card' }, rows)
: emptyState(trash ? 'trash' : 'lock', trash ? 'The vault trash is empty' : 'Nothing in the vault yet', trash ? 'Pages you delete from the vault appear here.' : 'Make a page and it is locked away with the rest.',
trash ? null : h('button', { class: 'btn', onclick: () => { location.hash = '#/vault/new'; } }, icon('add', 16), 'New page')),
trash && rows.length ? btn('Empty trash', 'trash', act(async () => {
if (!await confirmDialog({ title: 'Empty the vault trash?', body: 'Every page in it will be deleted and cannot be brought back.', confirm: 'Empty trash' })) return;
await api('DELETE', VAULT + '/trash'); list(true);
}), 'ghost danger') : null);
}
// one page of the vault
async function page(id) {
fill(root, skeleton());
let note = null;
if (id !== 'new') {
try { note = await api('GET', `${VAULT}/notes/${encodeURIComponent(id)}`); }
catch (e) {
if (e.status === 404) return fill(root, emptyState('notes', 'This page is not in the vault', 'It may have been deleted for good, or the link is wrong.', btn('Back to the vault', 'prev', () => { location.hash = '#/vault'; }, 'btn')));
throw e;
}
if (dead) return;
if (note.trashed) return fill(root, h('div', { class: 'notice notice-warning' }, icon('trash', 16), h('span', { class: 'grow' }, 'This page is in the vault trash.'),
btn('Restore', 'restore', act(async () => { await api('POST', `${VAULT}/notes/${note.id}/restore`); show(); }))));
}
const actions = h('div', { class: 'page-actions' }), crumbs = h('nav', { class: 'crumbs', 'aria-label': 'Where this page is' },
h('a', { href: '#/vault' }, 'Vault'));
const meta = h('p', { class: 'sub page-meta' });
ed = pageEditor({
note, vault: true,
onCreated(c) { note = c; history.replaceState(null, '', '#/vault/' + c.id); document.title = (c.name || 'New page') + ' · Vault · Mako'; draw(); },
onSaved(c) { note = c; meta.textContent = 'Edited ' + fmtTime(c.updated); document.title = (c.name || 'New page') + ' · Vault · Mako'; },
});
ed.onMode = () => draw();
view.toggleMode = () => ed.setMode(ed.mode === 'read' ? ed.lastEdit : 'read');
function draw() {
const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'How the page is shown' },
[['live', 'Live', 'Rendered, except the line you are on'], ['write', 'Edit', 'Plain markdown'], ['read', 'View', 'The finished page']].map(([m, label, tip]) =>
h('button', { class: 'seg-b' + (ed.mode === m ? ' on' : ''), 'aria-pressed': String(ed.mode === m), title: tip, onclick: () => ed.setMode(m) }, label)));
fill(actions, ed.status, h('span', { class: 'grow' }), seg, btn('Lock', 'lock', act(lock)),
btn('Delete', 'trash', act(async () => {
if (!note) { location.hash = '#/vault'; return; }
await ed.flush();
const n = note;
await api('DELETE', `${VAULT}/notes/${n.id}`);
toast('Moved to the vault trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `${VAULT}/notes/${n.id}/restore`); location.hash = '#/vault/' + n.id; show(); }) });
location.hash = '#/vault';
}), 'ghost danger'));
}
const pref = store.get('mode'), start = pref === 'read' && !(note && note.body.trim()) ? 'live' : pref || 'live';
fill(root, h('div', { class: 'page-top' }, crumbs, actions),
h('div', {}, ed.notice, h('div', { class: 'title-wrap' }, ed.titleEl, ed.titleErr), ed.wrap, meta));
ed.setMode(start);
const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); };
if (note) meta.textContent = 'Edited ' + fmtTime(note.updated);
draw();
view.title = note?.name || 'New page';
document.title = view.title + ' · Vault · Mako';
if (!note) setTimeout(() => ed.titleEl.focus());
}
view.start = act(async () => {
fill(root, skeleton());
const v = await api('GET', VAULT);
if (dead) return;
if (!v.exists) return create();
if (!v.unlocked) return unlock();
return arg ? page(arg) : list(false);
});
return view;
}