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:
91
extension/src/popup/components/__tests__/devices.test.ts
Normal file
91
extension/src/popup/components/__tests__/devices.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
91
extension/src/popup/components/devices.ts
Normal file
91
extension/src/popup/components/devices.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1060,3 +1060,76 @@ textarea {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: default;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user