fix(ext/settings): restore settings-security.ts from main (Stream C real implementation)

This commit is contained in:
adlee-was-taken
2026-05-03 21:51:08 -04:00
parent ddfb95d683
commit 8baef5b3cb

View File

@@ -1,17 +1,329 @@
// extension/src/popup/components/settings-security.ts /// Security settings section — three-state Recovery QR + Trusted Devices panel.
// Stub — real implementation provided by Stream C (DEV-C). ///
/// Exported contract:
/// renderSecuritySection(container, sessionHandle): renders into `container`
/// teardownSecuritySection(): removes any open QR modal
import { sendMessage, escapeHtml } from '../../shared/state';
import type { Device } from '../../shared/types';
// --- Relative time helper ---
function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return `${Math.floor(diff / 2592000)}mo ago`;
}
// --- Modal helpers ---
const MODAL_ID = 'relicario-qr-modal';
function removeModal(): void {
document.getElementById(MODAL_ID)?.remove();
}
function showQrModal(svgContent: string): void {
removeModal();
const overlay = document.createElement('div');
overlay.id = MODAL_ID;
overlay.style.cssText = [
'position:fixed', 'inset:0', 'z-index:9999',
'background:rgba(0,0,0,0.85)',
'display:flex', 'flex-direction:column',
'align-items:center', 'justify-content:center',
'padding:16px', 'box-sizing:border-box',
].join(';');
overlay.innerHTML = `
<div style="
background:#161b22; border:1px solid #30363d; border-radius:8px;
padding:16px; max-width:340px; width:100%; text-align:center;
">
<div style="font-size:13px; font-weight:600; margin-bottom:8px; color:#e6edf3;">
Recovery QR
</div>
<div style="font-size:11px; color:#8b949e; margin-bottom:12px;">
Print or store this QR. It encodes your reference image secret,
protected by your passphrase.
</div>
<div id="relicario-qr-svg" style="
background:#fff; border-radius:4px; padding:8px;
display:inline-block; max-width:280px; width:100%;
">
${svgContent}
</div>
<div style="display:flex; gap:8px; margin-top:12px; justify-content:center;">
<button id="relicario-qr-print" class="btn btn-primary" style="font-size:12px;">
Print
</button>
<button id="relicario-qr-done" class="btn" style="font-size:12px;">
Done
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById('relicario-qr-done')?.addEventListener('click', removeModal);
document.getElementById('relicario-qr-print')?.addEventListener('click', () => {
const win = window.open('', '_blank', 'width=400,height=500');
if (!win) return;
win.document.write(`
<!DOCTYPE html>
<html><head><title>Recovery QR</title>
<style>
body { margin: 0; display: flex; flex-direction: column; align-items: center;
font-family: sans-serif; padding: 24px; }
h2 { font-size: 16px; margin-bottom: 8px; }
p { font-size: 12px; color: #555; margin-bottom: 16px; text-align: center; }
svg { max-width: 280px; width: 100%; }
</style></head><body>
<h2>Relicario Recovery QR</h2>
<p>Scan with the Relicario app to recover your reference image secret.<br>
Keep this page in a safe physical location.</p>
${svgContent}
<script>window.onload = () => { window.print(); window.close(); }<\/script>
</body></html>
`);
win.document.close();
});
// Close on backdrop click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) removeModal();
});
}
// --- Main render ---
export async function renderSecuritySection( export async function renderSecuritySection(
container: HTMLElement, container: HTMLElement,
_sessionHandle: number | null, sessionHandle: number | null,
): Promise<void> { ): Promise<void> {
container.innerHTML = ` // Read timestamp from device-local storage (never the QR payload itself)
<div class="settings-section-placeholder"> const stored = await chrome.storage.local.get(['recovery_qr_generated_at']);
<span class="muted">Security settings — loading…</span> const generatedAt: number | null = (stored.recovery_qr_generated_at as number) ?? null;
const isUnlocked = sessionHandle !== null;
// --- QR status section ---
let qrStatusHtml: string;
if (generatedAt === null) {
qrStatusHtml = `
<div style="
display:flex; align-items:flex-start; gap:10px;
background:#2d1f00; border:1px solid #7c5719; border-radius:6px;
padding:10px; margin-bottom:12px;
">
<span style="font-size:16px;">⚠</span>
<div style="flex:1; font-size:12px;">
<div style="color:#e3a726; font-weight:600; margin-bottom:2px;">
No recovery QR generated
</div>
<div style="color:#8b949e;">
If you lose access to your reference image, you will be locked out permanently.
</div>
</div>
</div>
<button
class="btn btn-primary"
id="sec-generate-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="width:100%; font-size:12px; margin-bottom:4px;"
>
Generate recovery QR…
</button>
`;
} else {
qrStatusHtml = `
<div style="
display:flex; align-items:flex-start; gap:10px;
background:#0a2a1a; border:1px solid #238636; border-radius:6px;
padding:10px; margin-bottom:12px;
">
<span style="font-size:16px;">✓</span>
<div style="flex:1; font-size:12px;">
<div style="color:#3fb950; font-weight:600; margin-bottom:2px;">
Recovery QR set up
</div>
<div style="color:#8b949e;">
Generated ${relativeTime(generatedAt)}. Store the printout in a safe place.
</div>
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:4px;">
<button
class="btn"
id="sec-show-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="flex:1; font-size:12px;"
>
Show / print QR…
</button>
<button
class="btn"
id="sec-regenerate-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="flex:1; font-size:12px;"
>
Regenerate…
</button>
</div> </div>
`; `;
} }
export function teardownSecuritySection(): void { // --- Devices section ---
// no-op in stub const devicesResp = await sendMessage({ type: 'list_devices' });
let devicesHtml: string;
if (!devicesResp.ok) {
devicesHtml = `<p class="muted" style="font-size:12px;">Could not load devices.</p>`;
} else {
const devices = (devicesResp.data as { devices: Device[] }).devices;
const currentDeviceNameStored = await chrome.storage.local.get(['device_name']);
const currentDeviceName: string | undefined = currentDeviceNameStored.device_name as string | undefined;
if (devices.length === 0) {
devicesHtml = `<p class="muted" style="font-size:12px; text-align:center; margin-top:8px;">No devices registered.</p>`;
} else {
devicesHtml = devices.map((d) => {
const isCurrent = d.name === currentDeviceName;
return `
<div class="device-row" style="display:flex; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid #21262d;">
<div style="flex:1; min-width:0;">
<div style="font-size:12px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${escapeHtml(d.name)}${isCurrent ? ' <span style="color:#8b949e; font-weight:400; font-size:11px;">(this device)</span>' : ''}
</div>
<div style="font-size:11px; color:#8b949e;">added ${relativeTime(d.added_at)}</div>
</div>
${isCurrent ? '' : `
<button
class="btn sec-revoke-btn"
data-device-name="${escapeHtml(d.name)}"
style="font-size:11px; margin-left:8px; flex-shrink:0;"
>revoke</button>
`}
</div>
`;
}).join('');
}
}
// --- Assemble ---
container.innerHTML = `
<div class="settings-section" style="margin-top:0;">
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
Recovery QR
</div>
${qrStatusHtml}
<div id="sec-qr-error" style="font-size:11px; color:#f85149; margin-top:4px; min-height:14px;"></div>
</div>
<div class="settings-section" style="margin-top:16px;">
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
Trusted Devices
</div>
<div id="sec-devices-list">
${devicesHtml}
</div>
</div>
`;
// --- Wire handlers ---
const setQrError = (msg: string): void => {
const el = document.getElementById('sec-qr-error');
if (el) el.textContent = msg;
};
async function doGenerateQr(isRegen: boolean): Promise<void> {
const passphrase = prompt(
isRegen
? 'Enter your vault passphrase to regenerate the recovery QR:'
: 'Enter your vault passphrase to generate the recovery QR:',
);
if (!passphrase) return;
const btn = document.getElementById(isRegen ? 'sec-regenerate-qr' : 'sec-generate-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
if (!resp.ok) {
setQrError(`Failed: ${resp.error}`);
if (btn) { btn.disabled = false; btn.textContent = isRegen ? 'Regenerate…' : 'Generate recovery QR…'; }
return;
}
const svg = (resp.data as { svg: string }).svg;
const now = Math.floor(Date.now() / 1000);
// Store only the timestamp, NEVER the QR payload
await chrome.storage.local.set({ recovery_qr_generated_at: now });
showQrModal(svg);
// Re-render to reflect new state (timestamp now exists)
await renderSecuritySection(container, sessionHandle);
}
document.getElementById('sec-generate-qr')?.addEventListener('click', () => {
void doGenerateQr(false);
});
document.getElementById('sec-regenerate-qr')?.addEventListener('click', () => {
void doGenerateQr(true);
});
document.getElementById('sec-show-qr')?.addEventListener('click', async () => {
const passphrase = prompt('Enter your vault passphrase to view the recovery QR:');
if (!passphrase) return;
const btn = document.getElementById('sec-show-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
if (!resp.ok) {
setQrError(`Failed: ${resp.error}`);
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
return;
}
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
const svg = (resp.data as { svg: string }).svg;
showQrModal(svg);
});
// Revoke buttons
container.querySelectorAll<HTMLButtonElement>('.sec-revoke-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.dataset.deviceName;
if (!name) return;
if (!confirm(`Revoke "${name}"? This device will no longer be authorized.`)) return;
btn.disabled = true;
btn.textContent = '…';
const result = await sendMessage({ type: 'revoke_device', name });
if (result.ok) {
await sendMessage({ type: 'sync' });
// Re-render to refresh device list
await renderSecuritySection(container, sessionHandle);
} else {
btn.disabled = false;
btn.textContent = 'revoke';
setQrError(`Revoke failed: ${result.error}`);
}
});
});
}
export function teardownSecuritySection(): void {
removeModal();
} }