/// Device management view — list devices with revoke actions. import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import type { Device } from '../../shared/types'; import { relativeTime } from '../../shared/relative-time'; import { sshFingerprint } from '../../shared/ssh-fingerprint'; import { GLYPH_REVOKE } from '../../shared/glyphs'; interface RevokedEntry { name: string; public_key: string; revoked_at: number; revoked_by: string; } function detectDefaultDeviceName(): string { const ua = navigator.userAgent ?? ''; const platform = (navigator.platform ?? '').toLowerCase(); const isFirefox = /firefox/i.test(ua); const isEdge = /edg/i.test(ua); const isChrome = /chrome/i.test(ua) && !isEdge; const browser = isFirefox ? 'Firefox' : isEdge ? 'Edge' : isChrome ? 'Chrome' : 'Browser'; const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : platform.includes('linux') ? 'Linux' : 'Unknown'; return `${browser} on ${os}`; } export function teardown(): void { // No cleanup needed } /** * DEV-C P2: defensive per-slot rendering. The active list is the primary * feed — if it fails entirely, we still surface an error page. The * revoked list is secondary — its failure renders an inline "couldn't * load" slot but doesn't kill the page. */ function revokedLoadErrorHtml(): string { return `
▸ revoked devices

Couldn't load revoked devices.

`; } export async function renderDevices(app: HTMLElement): Promise { // Get current device name from local storage const stored = await chrome.storage.local.get(['device_name']); const currentDeviceName: string | undefined = stored.device_name as string | undefined; // Fetch active device list and revoked list in parallel. allSettled so a // rejected secondary feed doesn't kill the whole render. const [devicesSettled, revokedSettled] = await Promise.allSettled([ sendMessage({ type: 'list_devices' }), sendMessage({ type: 'list_revoked' }), ]); if (devicesSettled.status === 'rejected' || !devicesSettled.value.ok) { app.innerHTML = `

Failed to load devices

`; return; } // devicesSettled.value.ok is true here (guarded above), so .data is present. const devicesData = (devicesSettled.value as { ok: true; data: unknown }).data; const devices = (devicesData as { devices: Device[] }).devices; const revokedOk = revokedSettled.status === 'fulfilled' && revokedSettled.value.ok; const revokedDevices: RevokedEntry[] = revokedOk ? ((revokedSettled.value as { ok: true; data: unknown }).data as { revoked: RevokedEntry[] }).revoked : []; const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName); // Precompute fingerprints for all active devices. allSettled so one bad // public key doesn't kill the whole list — fall back to '(unknown)'. const fingerprints = new Map(); const fpResults = await Promise.allSettled( devices.map((d) => sshFingerprint(d.public_key).then((fp) => [d.name, fp] as const)), ); for (let i = 0; i < devices.length; i += 1) { const r = fpResults[i]; if (r.status === 'fulfilled' && r.value[1]) { fingerprints.set(r.value[0], r.value[1]); } else { fingerprints.set(devices[i].name, '(unknown)'); } } const activeDevicesHtml = devices.length === 0 ? `

No devices registered

` : devices.map((d) => { const isCurrentDevice = d.name === currentDeviceName; const fp = fingerprints.get(d.name) ?? '(unknown)'; const addedBy = d.added_by && d.added_by !== 'unknown' ? ` · by ${escapeHtml(d.added_by)}` : ''; return `
${escapeHtml(d.name)} ${isCurrentDevice ? '← you' : ``}
${escapeHtml(fp)}
added ${escapeHtml(relativeTime(d.added_at))}${addedBy}
`; }).join(''); const revokedSectionHtml = !revokedOk ? revokedLoadErrorHtml() : revokedDevices.length === 0 ? '' : `
▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}
${revokedDevices.map((r) => `
${escapeHtml(r.name)}
revoked ${escapeHtml(relativeTime(r.revoked_at))}${r.revoked_by !== 'unknown' ? ` · by ${escapeHtml(r.revoked_by)}` : ''}
`).join('')}
`; app.innerHTML = `

devices

${!isRegistered ? `
This device isn't registered.

Registering generates an ed25519 keypair and adds the public key to .relicario/devices.json on the remote.

` : ''} ${devices.length > 0 ? `
ACTIVE · ${devices.length}
` : ''} ${activeDevicesHtml} ${!revokedOk ? `
REVOKED · ?
` : (revokedDevices.length > 0 ? `
REVOKED · ${revokedDevices.length}
` : '')} ${revokedSectionHtml}
`; // Wire handlers document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); document.getElementById('register-btn')?.addEventListener('click', () => { const banner = document.querySelector('.device-banner'); if (!banner) return; const defaultName = detectDefaultDeviceName(); banner.innerHTML = `
`; document.getElementById('register-cancel-btn')?.addEventListener('click', () => { renderDevices(app); }); document.getElementById('register-confirm-btn')?.addEventListener('click', async () => { const input = document.getElementById('register-name-input') as HTMLInputElement | null; const name = input?.value.trim(); if (!name) { setState({ error: 'Device name is required' }); return; } const result = await sendMessage({ type: 'register_this_device', name }); if (result.ok) { renderDevices(app); } else { setState({ error: result.error }); } }); }); document.querySelectorAll('[data-revoke]').forEach((btn) => { btn.addEventListener('click', () => { const name = btn.dataset.revoke; if (!name) return; const panel = document.querySelector(`[data-confirm-for="${CSS.escape(name)}"]`); if (!panel) return; panel.hidden = false; panel.innerHTML = `

Revoke this device? It won't be able to sign commits or push changes after revocation.

`; btn.disabled = true; panel.querySelector('[data-revoke-cancel]')?.addEventListener('click', () => { panel.hidden = true; panel.innerHTML = ''; btn.disabled = false; }); panel.querySelector('[data-revoke-confirm]')?.addEventListener('click', async () => { const confirmBtn = panel.querySelector('[data-revoke-confirm]')!; confirmBtn.disabled = true; confirmBtn.textContent = '...'; const result = await sendMessage({ type: 'revoke_device', name }); if (result.ok) { await sendMessage({ type: 'sync' }); renderDevices(app); } else { setState({ error: result.error }); confirmBtn.disabled = false; confirmBtn.textContent = 'revoke'; } }); }); }); }