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:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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 */}
|
||||
|
||||
Reference in New Issue
Block a user