diff --git a/extension/src/popup/components/__tests__/devices.test.ts b/extension/src/popup/components/__tests__/devices.test.ts index 551091c..7e73520 100644 --- a/extension/src/popup/components/__tests__/devices.test.ts +++ b/extension/src/popup/components/__tests__/devices.test.ts @@ -91,4 +91,42 @@ describe('devices view', () => { expect(navigate).toHaveBeenCalledWith('list'); }); + + it('clicking register button reveals an inline name input', async () => { + (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Unknown' }); + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] }, + }); + + await renderDevices(app); + app.querySelector('#register-btn')!.click(); + + expect(app.querySelector('#register-name-input')).not.toBeNull(); + expect(app.querySelector('#register-confirm-btn')).not.toBeNull(); + }); + + it('confirming register sends register_this_device with the entered name', async () => { + (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Unknown' }); + // Initial list_devices. + (sendMessage as ReturnType) + .mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } }) + // register_this_device. + .mockResolvedValueOnce({ ok: true }) + // Re-render's list_devices. + .mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }, { name: 'Test Browser', public_key: 'q', added_at: 2 }] } }); + // Re-render also re-reads device_name from storage. + (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Test Browser' }); + + await renderDevices(app); + app.querySelector('#register-btn')!.click(); + const input = app.querySelector('#register-name-input')!; + input.value = 'Test Browser'; + app.querySelector('#register-confirm-btn')!.click(); + // Wait a microtask for the async handler to run. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + + expect(sendMessage).toHaveBeenCalledWith({ type: 'register_this_device', name: 'Test Browser' }); + }); }); diff --git a/extension/src/popup/components/__tests__/settings.test.ts b/extension/src/popup/components/__tests__/settings.test.ts new file mode 100644 index 0000000..98262a9 --- /dev/null +++ b/extension/src/popup/components/__tests__/settings.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderSettings } from '../settings'; + +vi.mock('../../../shared/state', () => ({ + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: vi.fn(() => false), + openVaultTab: vi.fn(), +})); + +import { sendMessage } from '../../../shared/state'; + +function settingsResponses() { + // Two parallel calls in renderSettings: get_settings + get_blacklist. + (sendMessage as ReturnType) + .mockResolvedValueOnce({ ok: true, data: { settings: { captureEnabled: false, captureStyle: 'bar' } } }) + .mockResolvedValueOnce({ ok: true, data: { blacklist: [] } }); +} + +describe('settings view', () => { + let app: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + (sendMessage as ReturnType).mockReset(); + }); + + it('renders a Sync now button', async () => { + settingsResponses(); + + await renderSettings(app); + + expect(app.querySelector('#sync-now-btn')).not.toBeNull(); + }); + + it('clicking Sync now sends a sync message and shows feedback on success', async () => { + settingsResponses(); + (sendMessage as ReturnType).mockResolvedValueOnce({ ok: true }); + + await renderSettings(app); + app.querySelector('#sync-now-btn')!.click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + + expect(sendMessage).toHaveBeenCalledWith({ type: 'sync' }); + const status = app.querySelector('#sync-status')!; + expect(status.textContent).toMatch(/synced/i); + }); + + it('shows the error when sync fails', async () => { + settingsResponses(); + (sendMessage as ReturnType).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' }); + + await renderSettings(app); + app.querySelector('#sync-now-btn')!.click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + + const status = app.querySelector('#sync-status')!; + expect(status.textContent).toMatch(/remote_unreachable/); + }); +}); diff --git a/extension/src/popup/components/devices.ts b/extension/src/popup/components/devices.ts index 0574888..ee7a7fb 100644 --- a/extension/src/popup/components/devices.ts +++ b/extension/src/popup/components/devices.ts @@ -13,6 +13,20 @@ function relativeTime(unixSec: number): string { 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 } @@ -64,11 +78,44 @@ export async function renderDevices(app: HTMLElement): Promise { // 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.getElementById('register-btn')?.addEventListener('click', () => { + const banner = document.querySelector('.device-banner'); + if (!banner) return; + const defaultName = detectDefaultDeviceName(); + banner.innerHTML = ` + + +
+ + +
+ `; + + 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('[data-revoke]').forEach((btn) => { diff --git a/extension/src/popup/components/settings.ts b/extension/src/popup/components/settings.ts index 6584b77..d4cff97 100644 --- a/extension/src/popup/components/settings.ts +++ b/extension/src/popup/components/settings.ts @@ -57,6 +57,8 @@ export async function renderSettings(app: HTMLElement): Promise {
+ +
@@ -77,6 +79,18 @@ export async function renderSettings(app: HTMLElement): Promise { document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash')); document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices')); + // Sync now button + document.getElementById('sync-now-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('sync-now-btn') as HTMLButtonElement | null; + const status = document.getElementById('sync-status'); + if (!btn || !status) return; + btn.disabled = true; + status.textContent = 'syncing...'; + const result = await sendMessage({ type: 'sync' }); + btn.disabled = false; + status.textContent = result.ok ? 'synced ✓' : `sync failed: ${result.error}`; + }); + // Capture enabled toggle document.getElementById('capture-enabled')?.addEventListener('change', async (e) => { const checked = (e.target as HTMLInputElement).checked; diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 7da418c..8b0b01d 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -60,6 +60,10 @@ function makeContentSender(pageUrl = 'https://example.com/'): chrome.runtime.Mes }; } +function makeVaultSender(): chrome.runtime.MessageSender { + return { url: `chrome-extension://relicario-test-id/vault.html`, id: 'relicario-test-id' }; +} + function makeExternalSender(): chrome.runtime.MessageSender { return { url: 'https://evil.example/', id: 'some-other-extension' }; } @@ -97,6 +101,10 @@ describe('router sender dispatch', () => { const res = await route(msg, state, makePopupSender()); expect(res).toMatchObject({ ok: true }); }); + it(`accepts popup-only "${msg.type}" from vault tab`, async () => { + const res = await route(msg, state, makeVaultSender()); + expect(res).toMatchObject({ ok: true }); + }); it(`rejects popup-only "${msg.type}" from content`, async () => { const res = await route(msg, state, makeContentSender()); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); diff --git a/extension/src/service-worker/router/index.ts b/extension/src/service-worker/router/index.ts index 94dcb35..3f61589 100644 --- a/extension/src/service-worker/router/index.ts +++ b/extension/src/service-worker/router/index.ts @@ -32,10 +32,11 @@ export async function route( sender: chrome.runtime.MessageSender, ): Promise { const popupUrl = chrome.runtime.getURL('popup.html'); + const vaultUrl = chrome.runtime.getURL('vault.html'); const setupUrl = chrome.runtime.getURL('setup.html'); const senderUrl = sender.url ?? ''; - const isPopup = senderUrl.startsWith(popupUrl); + const isPopup = senderUrl.startsWith(popupUrl) || senderUrl.startsWith(vaultUrl); const isSetup = senderUrl.startsWith(setupUrl); const isContent = sender.tab !== undefined && sender.frameId === 0 diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 00da03d..16f45b3 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -310,6 +310,24 @@ export async function handle( return { ok: true }; } + case 'register_this_device': { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + const keypair = JSON.parse(state.wasm.generate_device_keypair()) as { + public_key_hex: string; + private_key_base64: string; + }; + await chrome.storage.local.set({ + device_name: msg.name, + device_private_key: keypair.private_key_base64, + }); + await devices.addDevice(state.gitHost, { + name: msg.name, + public_key: keypair.public_key_hex, + added_at: Math.floor(Date.now() / 1000), + }); + return { ok: true }; + } + case 'revoke_device': { if (!state.gitHost) return { ok: false, error: 'vault_locked' }; await devices.revokeDevice(state.gitHost, msg.name); diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 934775a..a669523 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -40,6 +40,7 @@ export type PopupMessage = | { type: 'download_attachment'; itemId: string; attachmentId: string } | { type: 'list_devices' } | { type: 'add_device'; name: string; public_key: string } + | { type: 'register_this_device'; name: string } | { type: 'revoke_device'; name: string } | { type: 'list_trashed' } | { type: 'restore_item'; id: ItemId } @@ -148,7 +149,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'ack_autofill_origin', 'get_settings', 'update_settings', 'get_vault_settings', 'update_vault_settings', 'get_blacklist', 'remove_blacklist', 'upload_attachment', 'download_attachment', - 'list_devices', 'add_device', 'revoke_device', + 'list_devices', 'add_device', 'register_this_device', 'revoke_device', 'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash', 'get_field_history', 'get_session_config', 'update_session_config',