From 7fe54472b3ee445cc40558bc0d34abcfd4cae4de Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 26 Apr 2026 19:34:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(ext/popup):=20devices=20view=20=E2=80=94?= =?UTF-8?q?=20list=20devices=20with=20revoke=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/__tests__/devices.test.ts | 91 +++++++++++++++++++ extension/src/popup/components/devices.ts | 91 +++++++++++++++++++ extension/src/popup/styles.css | 73 +++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 extension/src/popup/components/__tests__/devices.test.ts create mode 100644 extension/src/popup/components/devices.ts diff --git a/extension/src/popup/components/__tests__/devices.test.ts b/extension/src/popup/components/__tests__/devices.test.ts new file mode 100644 index 0000000..44bc455 --- /dev/null +++ b/extension/src/popup/components/__tests__/devices.test.ts @@ -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 = '
'; + app = document.getElementById('app')!; + vi.clearAllMocks(); + }); + + it('renders empty state when no devices', async () => { + (sendMessage as ReturnType).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).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).mockResolvedValueOnce({ device_name: 'Unknown' }); + (sendMessage as ReturnType).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).mockResolvedValueOnce({ + ok: true, + data: { devices: [] }, + }); + + await renderDevices(app); + app.querySelector('#back-btn')?.click(); + + expect(navigate).toHaveBeenCalledWith('list'); + }); +}); diff --git a/extension/src/popup/components/devices.ts b/extension/src/popup/components/devices.ts new file mode 100644 index 0000000..5d70c37 --- /dev/null +++ b/extension/src/popup/components/devices.ts @@ -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 { + // 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 = `

Failed to load devices

`; + return; + } + + const devices = (resp.data as { devices: Device[] }).devices; + const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName); + + app.innerHTML = ` +
+
+ +

devices

+
+ ${!isRegistered ? ` +
+ ⚠ This device is not registered + +
+ ` : ''} + ${devices.length === 0 + ? `

No devices registered

` + : devices.map((d) => { + const isCurrentDevice = d.name === currentDeviceName; + return ` +
+
+ ${escapeHtml(d.name)}${isCurrentDevice ? ' ← you' : ''} + added ${relativeTime(d.added_at)} +
+ ${isCurrentDevice ? '' : ``} +
+ `; + }).join('')} +
+ `; + + // 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('[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 }); + } + }); + }); +} diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 6ca31da..267d3db 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -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; +}