DEV-C P2: Promise.all meant one rejected RPC failed the whole render.
allSettled + per-slot fallback keeps the active-devices surface usable
when the revoked-list feed (or one bad ssh fingerprint) is down.
Two call sites converted in devices.ts:
1. list_devices + list_revoked pair — revoked failures now render an
inline "couldn't load" slot instead of failing the page.
2. sshFingerprint map — one bad public key falls back to '(unknown)'
instead of killing the whole device list.
trash.ts only has a single sendMessage in its load path on this branch,
so it has no Promise.all to migrate. Plan was written against a slightly
different snapshot; documented divergence in report.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
240 lines
9.6 KiB
TypeScript
240 lines
9.6 KiB
TypeScript
/// 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 `
|
|
<details class="revoked-section">
|
|
<summary class="muted">▸ revoked devices</summary>
|
|
<div class="revoked-section__body">
|
|
<p class="muted">Couldn't load revoked devices.</p>
|
|
</div>
|
|
</details>
|
|
`;
|
|
}
|
|
|
|
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. 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 = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
|
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<string, string>();
|
|
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
|
|
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
|
: 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 `
|
|
<div class="device-row" data-device="${escapeHtml(d.name)}">
|
|
<div class="device-row__head">
|
|
<span class="device-row__name">${escapeHtml(d.name)}</span>
|
|
${isCurrentDevice
|
|
? '<span class="device-row__you">← you</span>'
|
|
: `<button class="glyph-btn" data-danger data-revoke="${escapeHtml(d.name)}" title="revoke" aria-label="revoke ${escapeHtml(d.name)}">${GLYPH_REVOKE}</button>`}
|
|
</div>
|
|
<div class="fingerprint">${escapeHtml(fp)}</div>
|
|
<div class="device-row__meta">added ${escapeHtml(relativeTime(d.added_at))}${addedBy}</div>
|
|
<div class="device-row__confirm" data-confirm-for="${escapeHtml(d.name)}" hidden></div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const revokedSectionHtml = !revokedOk
|
|
? revokedLoadErrorHtml()
|
|
: revokedDevices.length === 0 ? '' : `
|
|
<details class="revoked-section">
|
|
<summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
|
|
<div class="revoked-section__body">
|
|
${revokedDevices.map((r) => `
|
|
<div class="device-row device-row--revoked">
|
|
<div class="device-row__head">
|
|
<span class="device-row__name" style="text-decoration:line-through;opacity:0.6;">
|
|
${escapeHtml(r.name)}
|
|
</span>
|
|
</div>
|
|
<div class="device-row__meta">
|
|
revoked ${escapeHtml(relativeTime(r.revoked_at))}${r.revoked_by !== 'unknown' ? ` · by ${escapeHtml(r.revoked_by)}` : ''}
|
|
</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">
|
|
<div class="device-banner__title">This device isn't registered.</div>
|
|
<p class="device-banner__body muted">Registering generates an ed25519 keypair and adds the public key to <code>.relicario/devices.json</code> on the remote.</p>
|
|
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
|
</div>
|
|
` : ''}
|
|
${devices.length > 0 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
|
|
${activeDevicesHtml}
|
|
${!revokedOk ? `<div class="section-header">REVOKED · ?</div>` : (revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : '')}
|
|
${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', () => {
|
|
const name = btn.dataset.revoke;
|
|
if (!name) return;
|
|
const panel = document.querySelector<HTMLElement>(`[data-confirm-for="${CSS.escape(name)}"]`);
|
|
if (!panel) return;
|
|
panel.hidden = false;
|
|
panel.innerHTML = `
|
|
<p class="device-row__confirm-text">
|
|
Revoke this device? It won't be able to sign commits or push changes after revocation.
|
|
</p>
|
|
<div class="device-row__confirm-actions">
|
|
<button class="btn" data-revoke-cancel="${escapeHtml(name)}">cancel</button>
|
|
<button class="btn btn-danger" data-revoke-confirm="${escapeHtml(name)}">revoke</button>
|
|
</div>
|
|
`;
|
|
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<HTMLButtonElement>('[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';
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|