@papervault/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/package.json +51 -0
- package/src/compress.js +170 -0
- package/src/constants.js +19 -0
- package/src/crypto.js +184 -0
- package/src/index.js +22 -0
- package/src/kit.js +206 -0
- package/src/templates/index.js +4 -0
- package/src/templates/key.js +95 -0
- package/src/templates/printall.js +67 -0
- package/src/templates/selector.js +193 -0
- package/src/templates/styles.js +282 -0
- package/src/templates/vault.js +144 -0
package/src/kit.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// High-level orchestrator: turns a list of user-facing secrets into a printable
|
|
2
|
+
// PaperVault kit. Output is in-memory only — the caller decides whether to
|
|
3
|
+
// serve it from a localhost server, write to disk, or both.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
encrypt, splitKey, generateKeyAliases, generateVaultId,
|
|
7
|
+
secureRandomBytes,
|
|
8
|
+
} from './crypto.js';
|
|
9
|
+
import { compress, countUserContent } from './compress.js';
|
|
10
|
+
import {
|
|
11
|
+
generateVaultPage, buildVaultBody,
|
|
12
|
+
generateKeyPage, buildKeyBody,
|
|
13
|
+
generatePrintAllPage,
|
|
14
|
+
} from './templates/index.js';
|
|
15
|
+
import { LIMITS } from './constants.js';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_COLOR_PALETTE = [
|
|
18
|
+
'#FF0000', '#0000FF', '#008000', '#FFA500', '#800080',
|
|
19
|
+
'#A52A2A', '#000000', '#FF00FF', '#00FFFF', '#FFD700',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function pickThreeColors() {
|
|
23
|
+
const pool = [...DEFAULT_COLOR_PALETTE];
|
|
24
|
+
const picked = [];
|
|
25
|
+
for (let i = 0; i < 3 && pool.length > 0; i++) {
|
|
26
|
+
const idx = secureRandomBytes(1)[0] % pool.length;
|
|
27
|
+
picked.push(pool[idx]);
|
|
28
|
+
pool.splice(idx, 1);
|
|
29
|
+
}
|
|
30
|
+
return picked;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} KitPage
|
|
35
|
+
* @property {'vault'|'key'} kind
|
|
36
|
+
* @property {string} filename Filesystem-safe basename (no extension)
|
|
37
|
+
* @property {string} label Display label, e.g. "Vault PDF". For keys
|
|
38
|
+
* this is just the alias (the seq # is
|
|
39
|
+
* carried separately so the UI can style
|
|
40
|
+
* the two parts differently).
|
|
41
|
+
* @property {string|null} seq e.g. "Key 1" for key pages, null for vault
|
|
42
|
+
* @property {string|null} alias Two-word alias for key pages
|
|
43
|
+
* @property {string|null} custodian Optional custodian name
|
|
44
|
+
* @property {string} html Full standalone HTML document (for preview/save)
|
|
45
|
+
*
|
|
46
|
+
* @typedef {object} Kit
|
|
47
|
+
* @property {string} vaultId
|
|
48
|
+
* @property {string} vaultName
|
|
49
|
+
* @property {number} threshold
|
|
50
|
+
* @property {number} shares
|
|
51
|
+
* @property {string[]} keyAliases
|
|
52
|
+
* @property {string[]} keyShares
|
|
53
|
+
* @property {string} cipherKeyHex
|
|
54
|
+
* @property {string} cipherTextHex
|
|
55
|
+
* @property {string} cipherIvHex
|
|
56
|
+
* @property {string[]} colors
|
|
57
|
+
* @property {number} createdAt
|
|
58
|
+
* @property {KitPage[]} pages
|
|
59
|
+
* @property {string} printAllHtml Single doc containing every page, for
|
|
60
|
+
* one-shot printing of the whole packet.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a complete in-memory PaperVault kit.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} opts
|
|
67
|
+
* @param {Array<object>} opts.secrets
|
|
68
|
+
* @param {string} [opts.freeText]
|
|
69
|
+
* @param {string} opts.vaultName
|
|
70
|
+
* @param {number} opts.threshold
|
|
71
|
+
* @param {number} opts.shares
|
|
72
|
+
* @param {string[]} [opts.custodianNames]
|
|
73
|
+
* @param {string[]} [opts.colors]
|
|
74
|
+
* @returns {Promise<Kit>}
|
|
75
|
+
*/
|
|
76
|
+
export async function createKit(opts) {
|
|
77
|
+
const {
|
|
78
|
+
secrets,
|
|
79
|
+
freeText,
|
|
80
|
+
vaultName,
|
|
81
|
+
threshold,
|
|
82
|
+
shares,
|
|
83
|
+
custodianNames,
|
|
84
|
+
colors,
|
|
85
|
+
} = opts;
|
|
86
|
+
|
|
87
|
+
if (!vaultName || typeof vaultName !== 'string') {
|
|
88
|
+
throw new Error('createKit: vaultName is required.');
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isInteger(shares) || shares < 1 || shares > LIMITS.MAX_KEYS) {
|
|
91
|
+
throw new Error(`createKit: shares must be 1..${LIMITS.MAX_KEYS}.`);
|
|
92
|
+
}
|
|
93
|
+
if (!Number.isInteger(threshold) || threshold < 1 || threshold > shares) {
|
|
94
|
+
throw new Error('createKit: threshold must be 1..shares.');
|
|
95
|
+
}
|
|
96
|
+
if (shares > 1 && threshold < 2) {
|
|
97
|
+
throw new Error('createKit: Shamir requires threshold >= 2 when shares > 1.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Count USER CONTENT only (matches web app's getUserContentLength).
|
|
101
|
+
// The compressed JSON plaintext is larger due to bracket/quote overhead,
|
|
102
|
+
// but that's not the user-facing limit — MAX_STORAGE bounds the content
|
|
103
|
+
// the user actually typed so the QR stays scannable on real paper.
|
|
104
|
+
const userChars = countUserContent(secrets, freeText);
|
|
105
|
+
if (userChars === 0) {
|
|
106
|
+
throw new Error('createKit: no secrets to back up.');
|
|
107
|
+
}
|
|
108
|
+
if (userChars > LIMITS.MAX_STORAGE) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`createKit: ${userChars} chars of secret content exceeds the ${LIMITS.MAX_STORAGE}-char limit. ` +
|
|
111
|
+
`This matches papervault.xyz's storage cap so the QR codes stay scannable. ` +
|
|
112
|
+
`Trim the secrets, split into multiple vaults, or remove low-priority entries.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const plaintext = compress(secrets, freeText);
|
|
117
|
+
if (!plaintext) {
|
|
118
|
+
throw new Error('createKit: no secrets to back up.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { cipherText, cipherKey, cipherIV } = await encrypt(plaintext);
|
|
122
|
+
|
|
123
|
+
let keyShares;
|
|
124
|
+
if (shares === 1) {
|
|
125
|
+
keyShares = [cipherKey];
|
|
126
|
+
} else {
|
|
127
|
+
keyShares = await splitKey(cipherKey, shares, threshold);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const aliases = generateKeyAliases(shares);
|
|
131
|
+
const effectiveColors = colors && colors.length > 0 ? colors : pickThreeColors();
|
|
132
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
133
|
+
const vaultId = generateVaultId();
|
|
134
|
+
|
|
135
|
+
// Build both the standalone page (for preview/save) and the body fragment
|
|
136
|
+
// (for the print-all wrapper) from one shared template.
|
|
137
|
+
const vaultBaseOpts = {
|
|
138
|
+
vaultName, cipherText, cipherIV, threshold,
|
|
139
|
+
keyAliases: aliases, createdAt, colors: effectiveColors,
|
|
140
|
+
};
|
|
141
|
+
const vaultBody = await buildVaultBody(vaultBaseOpts);
|
|
142
|
+
const vaultHtml = await generateVaultPage(vaultBaseOpts);
|
|
143
|
+
|
|
144
|
+
/** @type {KitPage[]} */
|
|
145
|
+
const pages = [
|
|
146
|
+
{
|
|
147
|
+
kind: 'vault',
|
|
148
|
+
filename: 'vault',
|
|
149
|
+
label: 'Vault PDF',
|
|
150
|
+
seq: null,
|
|
151
|
+
alias: null,
|
|
152
|
+
custodian: null,
|
|
153
|
+
html: vaultHtml,
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/** Body fragments, parallel to pages[] — used to assemble print-all. */
|
|
158
|
+
const bodies = [vaultBody];
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < shares; i++) {
|
|
161
|
+
const alias = aliases[i];
|
|
162
|
+
const custodian = custodianNames?.[i] ?? null;
|
|
163
|
+
const keyBaseOpts = {
|
|
164
|
+
vaultName,
|
|
165
|
+
keyAlias: alias,
|
|
166
|
+
keyShare: keyShares[i],
|
|
167
|
+
createdAt,
|
|
168
|
+
colors: effectiveColors,
|
|
169
|
+
};
|
|
170
|
+
const keyBody = await buildKeyBody(keyBaseOpts);
|
|
171
|
+
const keyHtml = await generateKeyPage(keyBaseOpts);
|
|
172
|
+
|
|
173
|
+
pages.push({
|
|
174
|
+
kind: 'key',
|
|
175
|
+
filename: `key-${i + 1}-${alias}`,
|
|
176
|
+
label: alias,
|
|
177
|
+
seq: `Key ${i + 1}`,
|
|
178
|
+
alias,
|
|
179
|
+
custodian,
|
|
180
|
+
html: keyHtml,
|
|
181
|
+
});
|
|
182
|
+
bodies.push(keyBody);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const printAllHtml = generatePrintAllPage({
|
|
186
|
+
vaultName,
|
|
187
|
+
bodies,
|
|
188
|
+
autoPrint: true,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
vaultId,
|
|
193
|
+
vaultName,
|
|
194
|
+
threshold,
|
|
195
|
+
shares,
|
|
196
|
+
keyAliases: aliases,
|
|
197
|
+
keyShares,
|
|
198
|
+
cipherKeyHex: cipherKey,
|
|
199
|
+
cipherTextHex: cipherText,
|
|
200
|
+
cipherIvHex: cipherIV,
|
|
201
|
+
colors: effectiveColors,
|
|
202
|
+
createdAt,
|
|
203
|
+
pages,
|
|
204
|
+
printAllHtml,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import QRCode from 'qrcode';
|
|
2
|
+
import { SHARED_CSS, KEY_ASCII, DEFAULT_COLORS, esc, formatTime } from './styles.js';
|
|
3
|
+
|
|
4
|
+
async function qrDataUrl(payload) {
|
|
5
|
+
return QRCode.toDataURL(payload, {
|
|
6
|
+
errorCorrectionLevel: 'M',
|
|
7
|
+
width: 280,
|
|
8
|
+
margin: 2,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Inner key page content (no HTML/HEAD/BODY wrapper). Shared between the
|
|
14
|
+
* standalone preview and the multi-page print-all wrapper.
|
|
15
|
+
*/
|
|
16
|
+
export async function buildKeyBody(opts) {
|
|
17
|
+
const {
|
|
18
|
+
vaultName,
|
|
19
|
+
keyAlias,
|
|
20
|
+
keyShare,
|
|
21
|
+
createdAt,
|
|
22
|
+
colors = DEFAULT_COLORS,
|
|
23
|
+
} = opts;
|
|
24
|
+
|
|
25
|
+
// QR payload matches PDFKeyBackup.jsx exactly: { ident, key }
|
|
26
|
+
const qrPayload = JSON.stringify({ ident: keyAlias, key: keyShare });
|
|
27
|
+
const qrUrl = await qrDataUrl(qrPayload);
|
|
28
|
+
const colorStrip = colors.map(c => `<span class="color-swatch" style="background:${esc(c)}"></span>`).join('');
|
|
29
|
+
|
|
30
|
+
return `<div class="layout">
|
|
31
|
+
<div class="qr-column">
|
|
32
|
+
<div class="qr-box"><img src="${qrUrl}" alt="Key QR"></div>
|
|
33
|
+
<div class="do-not-fold">DO NOT FOLD</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="info-column">
|
|
36
|
+
<div class="header-row">
|
|
37
|
+
<h1 class="page-title">PAPERVAULT.XYZ KEY</h1>
|
|
38
|
+
<div class="color-strip">${colorStrip}</div>
|
|
39
|
+
</div>
|
|
40
|
+
<pre class="ascii-art">${KEY_ASCII}</pre>
|
|
41
|
+
<div class="key-alias-display">${esc(keyAlias)}</div>
|
|
42
|
+
<div class="panel">
|
|
43
|
+
<h3>How to Use This Key</h3>
|
|
44
|
+
<ol>
|
|
45
|
+
<li>Go to <strong>papervault.xyz/unlock</strong></li>
|
|
46
|
+
<li>Go offline</li>
|
|
47
|
+
<li>Scan your vault QR code</li>
|
|
48
|
+
<li>Scan the key(s) required to unlock the vault</li>
|
|
49
|
+
</ol>
|
|
50
|
+
<p>The unlock utility is open source: https://github.com/boazeb/papervault</p>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="panel-white">
|
|
53
|
+
<h3>Key Details</h3>
|
|
54
|
+
<div class="detail-row"><strong>Vault:</strong> ${esc(vaultName)}</div>
|
|
55
|
+
<div class="detail-row"><strong>Key Name:</strong> ${esc(keyAlias)}</div>
|
|
56
|
+
<div class="detail-row"><strong>Created:</strong> ${esc(formatTime(createdAt))}</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="panel-warning">
|
|
59
|
+
<div class="warning-title">🔒 KEEP SECURE</div>
|
|
60
|
+
<div class="warning-body">Store this key in a safe private location. Give it to its custodian in person, not over a network.</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Standalone key page (full HTML document).
|
|
68
|
+
*
|
|
69
|
+
* @param {object} opts See buildKeyBody, plus:
|
|
70
|
+
* @param {boolean} [opts.autoPrint]
|
|
71
|
+
* @returns {Promise<string>}
|
|
72
|
+
*/
|
|
73
|
+
export async function generateKeyPage(opts) {
|
|
74
|
+
const body = await buildKeyBody(opts);
|
|
75
|
+
const autoPrintScript = opts.autoPrint
|
|
76
|
+
? `<script>window.addEventListener('load', () => { setTimeout(() => window.print(), 200); });</script>`
|
|
77
|
+
: '';
|
|
78
|
+
const notice = opts.autoPrint
|
|
79
|
+
? '<div class="auto-print-notice">Opening print dialog…</div>'
|
|
80
|
+
: '';
|
|
81
|
+
|
|
82
|
+
return `<!doctype html>
|
|
83
|
+
<html lang="en">
|
|
84
|
+
<head>
|
|
85
|
+
<meta charset="utf-8">
|
|
86
|
+
<title>PaperVault Key — ${esc(opts.keyAlias)}</title>
|
|
87
|
+
<style>${SHARED_CSS}</style>
|
|
88
|
+
</head>
|
|
89
|
+
<body>
|
|
90
|
+
${notice}
|
|
91
|
+
${body}
|
|
92
|
+
${autoPrintScript}
|
|
93
|
+
</body>
|
|
94
|
+
</html>`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { SHARED_CSS, esc } from './styles.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single HTML document containing every kit page concatenated with page breaks,
|
|
5
|
+
* so a browser opens ONE native print dialog for the entire packet. From that
|
|
6
|
+
* dialog the user picks Print or "Save as PDF" from the OS dropdown.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.vaultName
|
|
10
|
+
* @param {string[]} opts.bodies Inner-body HTML strings, vault first then keys
|
|
11
|
+
* @param {boolean} [opts.autoPrint=true] Trigger window.print() + window.close() on load
|
|
12
|
+
* @returns {string} Complete HTML document
|
|
13
|
+
*/
|
|
14
|
+
export function generatePrintAllPage(opts) {
|
|
15
|
+
const { vaultName, bodies, autoPrint = true } = opts;
|
|
16
|
+
|
|
17
|
+
const pages = bodies.map((body, i) => `<section class="kit-page" data-page="${i}">${body}</section>`).join('\n');
|
|
18
|
+
|
|
19
|
+
// After the print dialog returns (resolves or is cancelled), close the
|
|
20
|
+
// popup window so it doesn't sit there empty. Only works because the
|
|
21
|
+
// popup was opened via window.open() from the selector.
|
|
22
|
+
const autoPrintScript = autoPrint ? `
|
|
23
|
+
<script>
|
|
24
|
+
window.addEventListener('load', () => {
|
|
25
|
+
setTimeout(() => {
|
|
26
|
+
try {
|
|
27
|
+
const before = Date.now();
|
|
28
|
+
window.print();
|
|
29
|
+
// Defer close: some browsers return synchronously from print(),
|
|
30
|
+
// others (Chrome) return after the dialog closes. Either way,
|
|
31
|
+
// closing after a short delay is fine because the popup is
|
|
32
|
+
// owned by us.
|
|
33
|
+
setTimeout(() => { try { window.close(); } catch (e) {} }, 250);
|
|
34
|
+
} catch (e) { /* noop */ }
|
|
35
|
+
}, 200);
|
|
36
|
+
});
|
|
37
|
+
</script>` : '';
|
|
38
|
+
|
|
39
|
+
return `<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<title>PaperVault Kit — ${esc(vaultName)}</title>
|
|
44
|
+
<style>${SHARED_CSS}</style>
|
|
45
|
+
<style>
|
|
46
|
+
.kit-page { padding: 0; margin: 0; }
|
|
47
|
+
.kit-page + .kit-page {
|
|
48
|
+
page-break-before: always;
|
|
49
|
+
break-before: page;
|
|
50
|
+
margin-top: 30px;
|
|
51
|
+
}
|
|
52
|
+
@media print {
|
|
53
|
+
.kit-page + .kit-page {
|
|
54
|
+
page-break-before: always;
|
|
55
|
+
break-before: page;
|
|
56
|
+
margin-top: 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
</style>
|
|
60
|
+
</head>
|
|
61
|
+
<body>
|
|
62
|
+
${autoPrint ? '<div class="auto-print-notice">Opening print dialog…</div>' : ''}
|
|
63
|
+
${pages}
|
|
64
|
+
${autoPrintScript}
|
|
65
|
+
</body>
|
|
66
|
+
</html>`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { esc } from './styles.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Orchestration page: shows the kit manifest with a single "Print kit" button
|
|
5
|
+
* that opens the full packet in one native print dialog. From there the user
|
|
6
|
+
* picks Print or "Save as PDF" via the OS dropdown. Bytes never touch disk.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.vaultName
|
|
10
|
+
* @param {number} opts.threshold
|
|
11
|
+
* @param {number} opts.shares
|
|
12
|
+
* @param {Array<{kind: 'vault'|'key', label: string, seq: string|null, alias: string|null, custodian: string|null}>} opts.pages
|
|
13
|
+
* @param {string} opts.printAllPath Server path that returns the full packet HTML
|
|
14
|
+
*/
|
|
15
|
+
export function generateSelectorPage(opts) {
|
|
16
|
+
const { vaultName, threshold, shares, pages, printAllPath } = opts;
|
|
17
|
+
|
|
18
|
+
const rows = pages.map((p) => {
|
|
19
|
+
const icon = p.kind === 'vault' ? '📄' : '🔑';
|
|
20
|
+
let labelHtml;
|
|
21
|
+
if (p.kind === 'vault') {
|
|
22
|
+
labelHtml = `<span class="row-label">${esc(p.label)}</span>`;
|
|
23
|
+
} else {
|
|
24
|
+
const custodian = p.custodian
|
|
25
|
+
? ` <span class="row-custodian">→ ${esc(p.custodian)}</span>`
|
|
26
|
+
: '';
|
|
27
|
+
labelHtml = `<span class="row-seq">${esc(p.seq)}</span><span class="row-alias">${esc(p.alias ?? p.label)}</span>${custodian}`;
|
|
28
|
+
}
|
|
29
|
+
return `<tr>
|
|
30
|
+
<td class="cell-icon">${icon}</td>
|
|
31
|
+
<td class="cell-label">${labelHtml}</td>
|
|
32
|
+
</tr>`;
|
|
33
|
+
}).join('');
|
|
34
|
+
|
|
35
|
+
return `<!doctype html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="utf-8">
|
|
39
|
+
<title>PaperVault.xyz Kit — ${esc(vaultName)}</title>
|
|
40
|
+
<style>
|
|
41
|
+
* { box-sizing: border-box; }
|
|
42
|
+
html, body { margin: 0; padding: 0; background: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; color: #2c3e50; }
|
|
43
|
+
.container { max-width: 720px; margin: 40px auto; padding: 0 20px; }
|
|
44
|
+
.card { background: #fff; border: 1px solid #e9ecef; border-radius: 12px; padding: 28px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
|
45
|
+
h1 { font-size: 22px; margin: 0 0 4px 0; }
|
|
46
|
+
.subtitle { color: #6c757d; font-size: 14px; margin: 0 0 24px 0; }
|
|
47
|
+
|
|
48
|
+
.print-bar {
|
|
49
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
50
|
+
gap: 16px; padding: 18px 20px;
|
|
51
|
+
background: linear-gradient(135deg, #0d6efd 0%, #0b5ed7 100%);
|
|
52
|
+
border-radius: 10px;
|
|
53
|
+
}
|
|
54
|
+
.print-bar-text { color: #fff; flex: 1; }
|
|
55
|
+
.print-bar-text .title { font-size: 15px; font-weight: 600; margin-bottom: 2px; }
|
|
56
|
+
.print-bar-text .hint { font-size: 12px; opacity: 0.85; }
|
|
57
|
+
.print-button {
|
|
58
|
+
font-size: 15px; font-weight: 600; padding: 10px 22px;
|
|
59
|
+
border-radius: 7px; border: none; background: #fff; color: #0d6efd;
|
|
60
|
+
cursor: pointer; font-family: inherit;
|
|
61
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
|
62
|
+
}
|
|
63
|
+
.print-button:hover { background: #f1f5fb; }
|
|
64
|
+
|
|
65
|
+
/* Lives below the print bar — only rendered after the first click,
|
|
66
|
+
so it doesn't reserve empty space and unbalance the bar. */
|
|
67
|
+
.print-status {
|
|
68
|
+
font-size: 12px;
|
|
69
|
+
color: #198754;
|
|
70
|
+
margin: 10px 4px 0 4px;
|
|
71
|
+
padding-left: 2px;
|
|
72
|
+
}
|
|
73
|
+
.print-bar-wrap { margin-bottom: 24px; }
|
|
74
|
+
|
|
75
|
+
table { width: 100%; border-collapse: collapse; }
|
|
76
|
+
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: #8a94a3; padding: 8px 6px; border-bottom: 1px solid #e9ecef; font-weight: 600; }
|
|
77
|
+
td { padding: 12px 6px; border-bottom: 1px solid #f0f2f5; vertical-align: middle; }
|
|
78
|
+
tr:last-child td { border-bottom: none; }
|
|
79
|
+
|
|
80
|
+
.cell-icon { width: 32px; font-size: 20px; }
|
|
81
|
+
.cell-label { /* contains seq + alias for keys, or just a label for vault */ }
|
|
82
|
+
|
|
83
|
+
.row-label { font-size: 14px; font-weight: 600; color: #2c3e50; }
|
|
84
|
+
.row-seq {
|
|
85
|
+
display: inline-block;
|
|
86
|
+
font-size: 11px;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
color: #8a94a3;
|
|
89
|
+
text-transform: uppercase;
|
|
90
|
+
letter-spacing: 0.5px;
|
|
91
|
+
margin-right: 10px;
|
|
92
|
+
min-width: 38px;
|
|
93
|
+
}
|
|
94
|
+
.row-alias {
|
|
95
|
+
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
|
96
|
+
font-size: 14px;
|
|
97
|
+
font-weight: 600;
|
|
98
|
+
color: #0d6efd;
|
|
99
|
+
letter-spacing: 0.2px;
|
|
100
|
+
}
|
|
101
|
+
.row-custodian {
|
|
102
|
+
margin-left: 8px;
|
|
103
|
+
color: #6c757d;
|
|
104
|
+
font-size: 12px;
|
|
105
|
+
font-style: italic;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 24px; padding-top: 20px; border-top: 1px solid #e9ecef; }
|
|
109
|
+
.footer-branding { font-size: 12px; color: #8a94a3; }
|
|
110
|
+
.footer-branding a { color: #6c757d; text-decoration: none; font-weight: 600; }
|
|
111
|
+
.footer-branding a:hover { color: #0d6efd; text-decoration: underline; }
|
|
112
|
+
.done-button { font-size: 14px; padding: 10px 18px; border-radius: 6px; border: 1px solid #dee2e6; background: #fff; color: #6c757d; cursor: pointer; font-family: inherit; }
|
|
113
|
+
.done-button:hover { background: #f8f9fa; color: #2c3e50; }
|
|
114
|
+
.done-button.ready { background: #198754; border-color: #198754; color: #fff; }
|
|
115
|
+
.done-button.ready:hover { background: #157347; }
|
|
116
|
+
|
|
117
|
+
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; z-index: 1000; }
|
|
118
|
+
.modal.open { display: flex; }
|
|
119
|
+
.modal-card { background: #fff; border-radius: 12px; padding: 28px; max-width: 420px; text-align: center; }
|
|
120
|
+
.modal-card h2 { margin: 0 0 12px 0; font-size: 20px; }
|
|
121
|
+
.modal-card p { color: #6c757d; margin: 0 0 8px 0; line-height: 1.5; }
|
|
122
|
+
.modal-card .fine { font-size: 12px; color: #8a94a3; margin-top: 14px; }
|
|
123
|
+
</style>
|
|
124
|
+
</head>
|
|
125
|
+
<body>
|
|
126
|
+
<div class="container">
|
|
127
|
+
<div class="card">
|
|
128
|
+
<h1>PaperVault.xyz Kit Ready</h1>
|
|
129
|
+
<p class="subtitle">${esc(vaultName)} — ${threshold} of ${shares} keys</p>
|
|
130
|
+
|
|
131
|
+
<div class="print-bar-wrap">
|
|
132
|
+
<div class="print-bar">
|
|
133
|
+
<div class="print-bar-text">
|
|
134
|
+
<div class="title">Print the whole packet</div>
|
|
135
|
+
<div class="hint">${pages.length} pages</div>
|
|
136
|
+
</div>
|
|
137
|
+
<button class="print-button" onclick="printKit()">Print kit</button>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="print-status" id="status"></div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<table>
|
|
143
|
+
<thead><tr><th></th><th>Contents</th></tr></thead>
|
|
144
|
+
<tbody>${rows}</tbody>
|
|
145
|
+
</table>
|
|
146
|
+
|
|
147
|
+
<div class="footer">
|
|
148
|
+
<div class="footer-branding">powered by <a href="https://papervault.xyz" target="_blank" rel="noopener">papervault.xyz</a></div>
|
|
149
|
+
<button id="doneBtn" class="done-button" onclick="doneAndExit()">Done</button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div class="modal" id="doneModal">
|
|
155
|
+
<div class="modal-card">
|
|
156
|
+
<h2>Done</h2>
|
|
157
|
+
<p>The server has stopped and the kit is no longer reachable from this tab.</p>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<script>
|
|
162
|
+
let actionCount = 0;
|
|
163
|
+
const PRINT_ALL = ${JSON.stringify(printAllPath)};
|
|
164
|
+
|
|
165
|
+
function printKit() {
|
|
166
|
+
const w = window.open(PRINT_ALL, 'papervault-print', 'width=900,height=1100');
|
|
167
|
+
if (!w) {
|
|
168
|
+
alert('Popup blocked. Please allow popups for this page so the print dialog can open.');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
actionCount++;
|
|
172
|
+
const status = document.getElementById('status');
|
|
173
|
+
status.textContent = actionCount === 1
|
|
174
|
+
? '✓ Print dialog opened.'
|
|
175
|
+
: '✓ Print dialog opened (' + actionCount + ' times).';
|
|
176
|
+
document.getElementById('doneBtn').classList.add('ready');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function doneAndExit() {
|
|
180
|
+
try { await fetch('/__shutdown', { method: 'POST' }); } catch (e) { /* already gone */ }
|
|
181
|
+
document.getElementById('doneModal').classList.add('open');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
window.addEventListener('beforeunload', (e) => {
|
|
185
|
+
if (actionCount === 0) {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
e.returnValue = 'You have not printed the kit yet. It will be lost.';
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
</script>
|
|
191
|
+
</body>
|
|
192
|
+
</html>`;
|
|
193
|
+
}
|