fix(frontend): generate Emergency Kit PDF client-side with actual Secret Key

The server-generated PDF had a placeholder for the Secret Key that was
never filled in client-side, making the Emergency Kit useless. Users
who relied on it could not recover their Secret Key on new devices.

Now generates the PDF entirely client-side via browser print dialog,
with the real Secret Key embedded. No server round-trip, key never
leaves the browser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-09 21:47:50 -05:00
parent 6c7dfe02f5
commit a3cc35c4b7

View File

@@ -3,7 +3,7 @@
*
* Displays the Secret Key (which NEVER touches the server) and provides:
* - Copy to clipboard button
* - Download Emergency Kit PDF (server-generated template without Secret Key)
* - Download Emergency Kit PDF (generated client-side with the actual Secret Key)
* - Mandatory acknowledgment checkbox before closing
*
* The Secret Key is only shown once — if the user closes this dialog
@@ -21,7 +21,101 @@ import {
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { authApi } from '@/lib/api';
/** Build a self-contained HTML page for the Emergency Kit with the actual Secret Key. */
function buildEmergencyKitHTML(email: string, secretKey: string, signinUrl: string, date: string): string {
// Escape HTML entities
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>TOD - Emergency Kit</title>
<style>
@page { size: A4; margin: 0; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; color: #1E293B; background: white; line-height: 1.5; }
.page { width: 210mm; min-height: 297mm; padding: 0; position: relative; }
.header { background: #0F172A; color: white; padding: 32px 40px; display: flex; align-items: center; gap: 16px; }
.logo { width: 48px; height: 48px; background: #38BDF8; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 700; color: #0F172A; flex-shrink: 0; }
.header-text h1 { font-size: 24px; font-weight: 700; letter-spacing: -0.025em; }
.header-text p { font-size: 13px; color: #94A3B8; margin-top: 2px; }
.content { padding: 32px 40px; }
.warning-box { background: #FEF3C7; border: 1px solid #FCD34D; border-radius: 8px; padding: 16px 20px; margin-bottom: 28px; font-size: 13px; color: #92400E; line-height: 1.6; }
.warning-box strong { display: block; margin-bottom: 4px; font-size: 14px; }
.field { margin-bottom: 20px; }
.field-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748B; margin-bottom: 6px; }
.field-value { font-size: 15px; color: #0F172A; padding: 10px 14px; background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 6px; }
.secret-key-box { border: 2px dashed #38BDF8; border-radius: 8px; padding: 18px 20px; text-align: center; background: #F0F9FF; margin-bottom: 20px; }
.secret-key-box .field-label { margin-bottom: 10px; }
.secret-key-value { font-family: 'SF Mono', 'Fira Code', Consolas, 'Courier New', monospace; font-size: 20px; font-weight: 600; letter-spacing: 0.05em; color: #0F172A; }
.write-in { margin-bottom: 28px; }
.write-in .field-label { margin-bottom: 8px; }
.write-line { border-bottom: 1px solid #CBD5E1; height: 32px; }
.separator { border: none; border-top: 1px solid #E2E8F0; margin: 24px 0; }
.instructions { background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 8px; padding: 20px 24px; margin-bottom: 28px; }
.instructions h3 { font-size: 14px; font-weight: 600; color: #0F172A; margin-bottom: 12px; }
.instructions ul { list-style: none; padding: 0; }
.instructions li { font-size: 13px; color: #475569; padding: 5px 0 5px 20px; position: relative; line-height: 1.5; }
.instructions li::before { content: ''; position: absolute; left: 0; top: 12px; width: 6px; height: 6px; background: #38BDF8; border-radius: 50%; }
.instructions li.warning { color: #B91C1C; font-weight: 500; }
.instructions li.warning::before { background: #EF4444; }
.footer { position: absolute; bottom: 0; left: 0; right: 0; padding: 16px 40px; border-top: 1px solid #E2E8F0; display: flex; justify-content: space-between; align-items: center; }
.footer-text { font-size: 11px; color: #94A3B8; }
.footer-accent { font-size: 11px; color: #38BDF8; font-weight: 600; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="logo">T</div>
<div class="header-text">
<h1>Emergency Kit</h1>
<p>TOD Zero-Knowledge Recovery</p>
</div>
</div>
<div class="content">
<div class="warning-box">
<strong>Keep this document safe</strong>
This Emergency Kit is your only way to recover access if you lose your Secret Key.
Store it in a secure location such as a home safe or safety deposit box.
</div>
<div class="field">
<div class="field-label">Email Address</div>
<div class="field-value">${esc(email)}</div>
</div>
<div class="field">
<div class="field-label">Sign-in URL</div>
<div class="field-value">${esc(signinUrl)}</div>
</div>
<div class="secret-key-box">
<div class="field-label">Secret Key</div>
<div class="secret-key-value">${esc(secretKey)}</div>
</div>
<div class="write-in">
<div class="field-label">Master Password (write by hand)</div>
<div class="write-line"></div>
</div>
<hr class="separator">
<div class="instructions">
<h3>Instructions</h3>
<ul>
<li>This Emergency Kit contains your Secret Key needed to log in on new devices.</li>
<li>Store this document in a safe place — a home safe, safety deposit box, or other secure location.</li>
<li>Do NOT store this document digitally alongside your password.</li>
<li>Consider writing your Master Password on this sheet and storing it securely.</li>
<li class="warning">If you lose both your Emergency Kit and forget your Secret Key, your encrypted data cannot be recovered. There is no reset mechanism.</li>
</ul>
</div>
</div>
<div class="footer">
<span class="footer-text">Generated ${esc(date)} — TOD</span>
<span class="footer-accent">CONFIDENTIAL</span>
</div>
</div>
</body>
</html>`;
}
interface EmergencyKitDialogProps {
open: boolean;
@@ -63,25 +157,31 @@ export function EmergencyKitDialog({
}
}, [secretKey]);
const handleDownloadPDF = useCallback(async () => {
const handleDownloadPDF = useCallback(() => {
setDownloading(true);
try {
const blob = await authApi.getEmergencyKitPDF();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'MikroTik-Portal-Emergency-Kit.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast.success('Emergency Kit PDF downloaded');
const today = new Date().toISOString().split('T')[0];
const html = buildEmergencyKitHTML(email, secretKey, window.location.origin, today);
const printWindow = window.open('', '_blank');
if (!printWindow) {
toast.error('Pop-up blocked. Please allow pop-ups and try again.');
return;
}
printWindow.document.write(html);
printWindow.document.close();
// Wait for content to render then trigger print dialog (Save as PDF)
printWindow.onload = () => {
printWindow.print();
};
// Fallback if onload doesn't fire (some browsers)
setTimeout(() => printWindow.print(), 500);
toast.success('Print dialog opened — choose "Save as PDF" to download');
} catch {
toast.error('Failed to download Emergency Kit PDF');
toast.error('Failed to generate Emergency Kit');
} finally {
setDownloading(false);
}
}, []);
}, [email, secretKey]);
return (
<Dialog open={open} onOpenChange={() => {}}>
@@ -146,8 +246,8 @@ export function EmergencyKitDialog({
{/* Instructions */}
<div className="mt-3 rounded-md bg-surface-secondary p-3 text-xs text-text-secondary leading-relaxed">
Write your Secret Key on the Emergency Kit PDF after printing it, or save it
in your password manager. Do NOT store it digitally alongside your password.
The PDF includes your Secret Key. Print it or save it securely.
You can also store the key in your password manager. Do NOT store it alongside your password.
</div>
{/* Help toggle */}