chore(ext/settings): stub settings-security.ts (DEV-C replaces implementation)
This commit is contained in:
@@ -1,329 +1,17 @@
|
||||
/// Security settings section — three-state Recovery QR + Trusted Devices panel.
|
||||
///
|
||||
/// 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 ---
|
||||
// extension/src/popup/components/settings-security.ts
|
||||
// Stub — real implementation provided by Stream C (DEV-C).
|
||||
|
||||
export async function renderSecuritySection(
|
||||
container: HTMLElement,
|
||||
sessionHandle: number | null,
|
||||
_sessionHandle: number | null,
|
||||
): Promise<void> {
|
||||
// Read timestamp from device-local storage (never the QR payload itself)
|
||||
const stored = await chrome.storage.local.get(['recovery_qr_generated_at']);
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Devices section ---
|
||||
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 class="settings-section-placeholder">
|
||||
<span class="muted">Security settings — loading…</span>
|
||||
</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();
|
||||
// no-op in stub
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user