Files
relicario/extension/src/popup/components/devices.ts
adlee-was-taken c67d484152 feat(extension): update devices UI for new auth model
- Show revoked devices in collapsible section with strikethrough styling
- Fetch revoked.json via new list_revoked message + router case
- Registration flow uses register_device WASM API (private keys internal)
- Display revoked_by and timestamp for each revoked entry
- Update setup wizard to use new register_device API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:29:31 -04:00

180 lines
6.4 KiB
TypeScript

/// Device management view — list devices with revoke actions.
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { Device } from '../../shared/types';
interface RevokedEntry {
name: string;
public_key: string;
revoked_at: number;
revoked_by: string;
}
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`;
}
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
}
export async function renderDevices(app: HTMLElement): Promise<void> {
// 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
const [devicesResp, revokedResp] = await Promise.all([
sendMessage({ type: 'list_devices' }),
sendMessage({ type: 'list_revoked' }),
]);
if (!devicesResp.ok) {
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
return;
}
const devices = (devicesResp.data as { devices: Device[] }).devices;
const revokedDevices: RevokedEntry[] = revokedResp.ok
? (revokedResp.data as { revoked: RevokedEntry[] }).revoked
: [];
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
const activeDevicesHtml = devices.length === 0
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
: devices.map((d) => {
const isCurrentDevice = d.name === currentDeviceName;
return `
<div class="device-row">
<div class="device-row__info">
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
</div>
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
</div>
`;
}).join('');
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
<details class="revoked-section" style="margin-top:16px;">
<summary class="muted" style="cursor:pointer;font-size:0.85em;">
${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}
</summary>
<div style="margin-top:8px;">
${revokedDevices.map((r) => `
<div class="device-row device-row--revoked">
<div class="device-row__info">
<span class="device-row__name" style="text-decoration:line-through;opacity:0.5;">
${escapeHtml(r.name)}
</span>
<span class="device-row__meta">
revoked ${relativeTime(r.revoked_at)}
${r.revoked_by !== 'unknown' ? ` by ${escapeHtml(r.revoked_by)}` : ''}
</span>
</div>
</div>
`).join('')}
</div>
</details>
`;
app.innerHTML = `
<div class="pad">
<div class="devices-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">devices</h3>
</div>
${!isRegistered ? `
<div class="device-banner">
<span>⚠ This device is not registered</span>
<button class="btn btn-primary" id="register-btn">Register this device</button>
</div>
` : ''}
${activeDevicesHtml}
${revokedSectionHtml}
</div>
`;
// 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 = `
<label class="label" for="register-name-input" style="display:block;margin-bottom:4px;">
Name this device
</label>
<input
id="register-name-input"
type="text"
value="${escapeHtml(defaultName)}"
style="width:100%;margin-bottom:8px;"
>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary" id="register-confirm-btn">Register</button>
<button class="btn" id="register-cancel-btn">Cancel</button>
</div>
`;
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<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.dataset.revoke;
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' });
renderDevices(app);
} else {
setState({ error: result.error });
}
});
});
}