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.
|
// extension/src/popup/components/settings-security.ts
|
||||||
///
|
// 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> {
|
||||||
// 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 = `
|
container.innerHTML = `
|
||||||
<div class="settings-section" style="margin-top:0;">
|
<div class="settings-section-placeholder">
|
||||||
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
|
<span class="muted">Security settings — loading…</span>
|
||||||
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>
|
</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 {
|
export function teardownSecuritySection(): void {
|
||||||
removeModal();
|
// no-op in stub
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user