Closes three audit gaps in one pass: 1. Sync now button in the popup settings view (📤). Triggers the existing { type: 'sync' } SW message and surfaces success / failure inline. The SW message was already wired but had no UI entry point. 2. Device registration from the popup. The "Register this device" button on the devices view used to error out with a "not yet implemented" message; it now opens an inline name input (default = browser+OS), and on confirm sends a new register_this_device SW message that generates an ed25519 keypair via WASM, persists private_key + name to chrome.storage.local, and writes the public key to the remote devices.json. No setup-wizard detour. 3. Vault tab is now an authorized sender for popup-only SW messages. The router accepts vault.html alongside popup.html, so the fullscreen tab can drive the same flows. Test covers acceptance from the vault tab. New SW message: register_this_device { name }. Added to PopupMessage and POPUP_ONLY_TYPES, handled in router/popup-only.ts. Tests: 5 new vitest cases (3 in settings.test.ts, 2 in devices.test.ts) + 1 router test for vault-tab acceptance. All 194 extension tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
139 lines
5.1 KiB
TypeScript
139 lines
5.1 KiB
TypeScript
/// Device management view — list devices with revoke actions.
|
|
|
|
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
|
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`;
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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', () => {
|
|
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', 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 });
|
|
}
|
|
});
|
|
});
|
|
}
|