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 = ``;
+ return;
+ }
+
+ const devices = (resp.data as { devices: Device[] }).devices;
+ const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
+
+ app.innerHTML = `
+
+
+ ${!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;
+}