feat(ext/popup): devices view — list devices with revoke actions

Shows registered devices with "← you" indicator on current device.
Revoke button on other devices. Unregistered banner if current
device not in list.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-26 19:34:30 -04:00
parent 9fbf9bb3ee
commit 7fe54472b3
3 changed files with 255 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderDevices } from '../devices';
// Mock chrome.storage.local
// @ts-expect-error test harness
globalThis.chrome = {
storage: {
local: {
get: vi.fn().mockResolvedValue({ device_name: 'Chrome on Linux' }),
},
},
};
// Mock popup module
vi.mock('../../popup', () => ({
setState: vi.fn(),
sendMessage: vi.fn(),
navigate: vi.fn(),
escapeHtml: (s: string) => s,
}));
import { sendMessage, navigate } from '../../popup';
describe('devices view', () => {
let app: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
app = document.getElementById('app')!;
vi.clearAllMocks();
});
it('renders empty state when no devices', async () => {
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: { devices: [] },
});
await renderDevices(app);
expect(app.innerHTML).toContain('No devices registered');
});
it('renders devices with "you" indicator on current device', async () => {
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: {
devices: [
{ name: 'Chrome on Linux', public_key: 'abc', added_at: 1000 },
{ name: 'CLI', public_key: 'def', added_at: 500 },
],
},
});
await renderDevices(app);
expect(app.innerHTML).toContain('Chrome on Linux');
expect(app.innerHTML).toContain('← you');
expect(app.innerHTML).toContain('CLI');
// Current device should not have revoke button
const rows = app.querySelectorAll('.device-row');
expect(rows[0].querySelector('[data-revoke]')).toBeNull();
expect(rows[1].querySelector('[data-revoke]')).not.toBeNull();
});
it('shows unregistered banner when current device not in list', async () => {
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: {
devices: [{ name: 'CLI', public_key: 'abc', added_at: 1000 }],
},
});
await renderDevices(app);
expect(app.innerHTML).toContain('This device is not registered');
});
it('back button navigates to list', async () => {
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: { devices: [] },
});
await renderDevices(app);
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
expect(navigate).toHaveBeenCalledWith('list');
});
});

View File

@@ -0,0 +1,91 @@
/// Device management view — list devices with revoke actions.
import { setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { Device } from '../../shared/types';
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`;
}
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 device list
const resp = await sendMessage({ type: 'list_devices' });
if (!resp.ok) {
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
return;
}
const devices = (resp.data as { devices: Device[] }).devices;
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
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>
` : ''}
${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('')}
</div>
`;
// Wire handlers
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('register-btn')?.addEventListener('click', async () => {
// Generate keypair and register
// This would need WASM access - for now, redirect to a registration flow
// The full implementation happens in Task 12 (setup wizard integration)
setState({ error: 'Device registration from here is not yet implemented. Use setup wizard.' });
});
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 });
}
});
});
}

View File

@@ -1060,3 +1060,76 @@ textarea {
opacity: 0.5;
cursor: default;
}
/* --- Devices view --- */
.devices-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.device-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
background: #3d1f00;
border: 1px solid #9e6a03;
border-radius: 4px;
margin-bottom: 12px;
font-size: 12px;
color: #f0c674;
}
.device-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
border-radius: 4px;
background: #161b22;
margin-bottom: 6px;
}
.device-row__info {
flex: 1;
min-width: 0;
}
.device-row__name {
display: block;
font-size: 13px;
color: #c9d1d9;
}
.device-row__you {
font-size: 11px;
color: #58a6ff;
}
.device-row__meta {
font-size: 11px;
color: #8b949e;
}
.device-row__revoke {
font-size: 11px;
padding: 4px 8px;
background: #da3633;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.device-row__revoke:hover {
background: #f85149;
}
.device-row__revoke:disabled {
opacity: 0.5;
cursor: default;
}