From bd13854f5940d472592979d4015f5d5bd400ab15 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 02:19:31 -0400 Subject: [PATCH] docs: vault tab + session timeout implementation plan 7 tasks: session timer, popup navigation, vault scaffold, shared state host, device settings, router fix, manual testing. Co-Authored-By: Claude --- .../2026-04-27-vault-tab-session-timeout.md | 1441 +++++++++++++++++ 1 file changed, 1441 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-vault-tab-session-timeout.md diff --git a/docs/superpowers/plans/2026-04-27-vault-tab-session-timeout.md b/docs/superpowers/plans/2026-04-27-vault-tab-session-timeout.md new file mode 100644 index 0000000..ae055a0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-vault-tab-session-timeout.md @@ -0,0 +1,1441 @@ +# Vault Tab UI + Session Timeout — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a full "desktop-like" vault UI in a browser tab with sidebar+detail layout, plus configurable session timeout shared between popup and vault tab. + +**Architecture:** New `vault.html` entry point with its own state management and hash-based routing, rendered as a sidebar+pane layout. Session timeout lives in the service worker via a new `session-timer.ts` module that resets on every message and broadcasts `session_expired` to all views. Both popup and vault tab import the same form/detail component renderers — vault passes its right pane element, popup passes its app element. + +**Tech Stack:** TypeScript, webpack (new entry point), Chrome MV3 APIs (chrome.commands, chrome.runtime.sendMessage, chrome.storage.local) + +--- + +### Task 1: Session Timer — Service Worker + +**Files:** +- Create: `src/service-worker/session-timer.ts` +- Modify: `src/service-worker/index.ts` +- Modify: `src/service-worker/session.ts` +- Modify: `src/shared/messages.ts` +- Test: `src/service-worker/__tests__/session-timer.test.ts` + +- [ ] **Step 1: Define session config types in shared/messages.ts** + +Add the new message types and session config type. In `src/shared/messages.ts`, add to the `PopupMessage` union: + +```typescript +| { type: 'get_session_config' } +| { type: 'update_session_config'; config: SessionTimeoutConfig } +``` + +Add the config type at the top of the file: + +```typescript +export type SessionTimeoutConfig = + | { mode: 'inactivity'; minutes: number } + | { mode: 'every_time' }; +``` + +Add both new types to the `POPUP_ONLY_TYPES` set. + +- [ ] **Step 2: Write the failing test for session-timer.ts** + +Create `src/service-worker/__tests__/session-timer.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { resetTimer, stopTimer, getConfig, setConfig, onExpired } from '../session-timer'; + +describe('session-timer', () => { + beforeEach(() => { + vi.useFakeTimers(); + stopTimer(); + // Reset to default + setConfig({ mode: 'inactivity', minutes: 15 }); + }); + + afterEach(() => { + vi.useRealTimers(); + stopTimer(); + }); + + it('fires callback after inactivity timeout', () => { + const cb = vi.fn(); + onExpired(cb); + resetTimer(); + vi.advanceTimersByTime(15 * 60 * 1000); + expect(cb).toHaveBeenCalledOnce(); + }); + + it('resets the timer on each call to resetTimer', () => { + const cb = vi.fn(); + onExpired(cb); + resetTimer(); + vi.advanceTimersByTime(14 * 60 * 1000); + resetTimer(); // reset before it fires + vi.advanceTimersByTime(14 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(cb).toHaveBeenCalledOnce(); + }); + + it('does not fire when mode is every_time', () => { + const cb = vi.fn(); + onExpired(cb); + setConfig({ mode: 'every_time' }); + resetTimer(); + vi.advanceTimersByTime(60 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + }); + + it('respects updated minutes', () => { + const cb = vi.fn(); + onExpired(cb); + setConfig({ mode: 'inactivity', minutes: 5 }); + resetTimer(); + vi.advanceTimersByTime(5 * 60 * 1000); + expect(cb).toHaveBeenCalledOnce(); + }); + + it('getConfig returns current config', () => { + setConfig({ mode: 'inactivity', minutes: 30 }); + expect(getConfig()).toEqual({ mode: 'inactivity', minutes: 30 }); + }); + + it('stopTimer prevents firing', () => { + const cb = vi.fn(); + onExpired(cb); + resetTimer(); + stopTimer(); + vi.advanceTimersByTime(60 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd /home/alee/Sources/relicario/extension && bun test session-timer` +Expected: FAIL — module not found + +- [ ] **Step 4: Implement session-timer.ts** + +Create `src/service-worker/session-timer.ts`: + +```typescript +import type { SessionTimeoutConfig } from '../shared/messages'; + +let config: SessionTimeoutConfig = { mode: 'inactivity', minutes: 15 }; +let timerId: ReturnType | null = null; +let expiredCallback: (() => void) | null = null; + +export function onExpired(cb: () => void): void { + expiredCallback = cb; +} + +export function getConfig(): SessionTimeoutConfig { + return config; +} + +export function setConfig(c: SessionTimeoutConfig): void { + config = c; + stopTimer(); +} + +export function resetTimer(): void { + stopTimer(); + if (config.mode === 'every_time') return; + timerId = setTimeout(() => { + timerId = null; + expiredCallback?.(); + }, config.minutes * 60 * 1000); +} + +export function stopTimer(): void { + if (timerId !== null) { + clearTimeout(timerId); + timerId = null; + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd /home/alee/Sources/relicario/extension && bun test session-timer` +Expected: all 6 tests PASS + +- [ ] **Step 6: Wire timer into service worker index.ts** + +In `src/service-worker/index.ts`, import the timer and wire it up: + +```typescript +import { resetTimer, onExpired, setConfig, getConfig } from './session-timer'; +import { clearCurrent } from './session'; +``` + +In the message listener, after `state.wasm` is initialized and before `route()`, add timer reset: + +```typescript +resetTimer(); +``` + +Add the expiration handler at module scope (after `state` is defined): + +```typescript +onExpired(() => { + clearCurrent(); + state.manifest = null; + chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {}); +}); +``` + +Load saved config from `chrome.storage.local` during init: + +```typescript +chrome.storage.local.get('session_timeout').then((result) => { + if (result.session_timeout) setConfig(result.session_timeout); +}); +``` + +- [ ] **Step 7: Add session config handlers to router/popup-only.ts** + +In `src/service-worker/router/popup-only.ts`, add two new cases: + +```typescript +case 'get_session_config': { + const { getConfig } = await import('../session-timer'); + return { ok: true, data: { config: getConfig() } }; +} + +case 'update_session_config': { + const { setConfig, resetTimer } = await import('../session-timer'); + const config = (msg as { config: SessionTimeoutConfig }).config; + setConfig(config); + resetTimer(); + await chrome.storage.local.set({ session_timeout: config }); + return { ok: true }; +} +``` + +Import `SessionTimeoutConfig` from `../../shared/messages`. + +- [ ] **Step 8: Build and run tests** + +Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5 && bun test session-timer` +Expected: Build succeeds, tests pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/service-worker/session-timer.ts src/service-worker/__tests__/session-timer.test.ts src/service-worker/index.ts src/service-worker/router/popup-only.ts src/shared/messages.ts +git commit -m "feat(ext/sw): add session inactivity timeout with configurable timer" +``` + +--- + +### Task 2: Popup Listens for Session Expiry + "Open Vault" Links + +**Files:** +- Modify: `src/popup/popup.ts` +- Modify: `src/popup/components/unlock.ts` +- Modify: `src/popup/components/item-list.ts` + +- [ ] **Step 1: Add session_expired listener to popup.ts** + +In `src/popup/popup.ts`, in the `DOMContentLoaded` handler (after `sendMessage({ type: 'is_unlocked' })`), add a listener: + +```typescript +chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'session_expired') { + currentState.view = 'locked'; + currentState.error = null; + currentState.selectedItem = null; + currentState.selectedId = null; + render(); + } +}); +``` + +- [ ] **Step 2: Add openVaultTab helper to popup.ts** + +Export a helper function in `src/popup/popup.ts`: + +```typescript +export function openVaultTab(hash?: string): void { + const url = chrome.runtime.getURL('vault.html') + (hash ? `#${hash}` : ''); + chrome.tabs.create({ url }); +} +``` + +- [ ] **Step 3: Add "Open vault" link to unlock screen** + +In `src/popup/components/unlock.ts`, find the settings button at the bottom and add an "open vault" button next to it: + +```html + +``` + +Wire the listener: + +```typescript +document.getElementById('open-vault-btn')?.addEventListener('click', () => openVaultTab()); +``` + +Import `openVaultTab` from `../popup`. + +- [ ] **Step 4: Add "Open vault" button and Shift+F to item list toolbar** + +In `src/popup/components/item-list.ts`, add a small button to the toolbar row (after lock button): + +```html + +``` + +Wire the listener: + +```typescript +document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab()); +``` + +In `handleListKeydown`, add Shift+F handler before the existing key checks: + +```typescript +if (e.key === 'F' && e.shiftKey) { + e.preventDefault(); + openVaultTab(); + return; +} +``` + +Import `openVaultTab` from `../popup`. + +- [ ] **Step 5: Update popOutToTab to use vault.html** + +In `src/popup/popup.ts`, change `popOutToTab()` to open `vault.html` with a hash: + +```typescript +export function popOutToTab(): void { + const state = getState(); + if (state.newType) { + openVaultTab(`add/${state.newType}`); + } else if (state.selectedId) { + openVaultTab(`${state.view}/${state.selectedId}`); + } else { + openVaultTab(); + } + window.close(); +} +``` + +- [ ] **Step 6: Build and smoke test** + +Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5` +Expected: Build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add src/popup/popup.ts src/popup/components/unlock.ts src/popup/components/item-list.ts +git commit -m "feat(ext/popup): session expiry listener, open-vault links, Shift+F shortcut" +``` + +--- + +### Task 3: Webpack + Manifest + vault.html Scaffold + +**Files:** +- Create: `src/vault/vault.html` +- Create: `src/vault/vault.css` +- Create: `src/vault/vault.ts` +- Modify: `webpack.config.js` +- Modify: `manifest.json` + +- [ ] **Step 1: Create vault.html** + +Create `src/vault/vault.html`: + +```html + + + + + relicario — vault + + + +
+ + + +``` + +- [ ] **Step 2: Create vault.css with shared base styles and layout** + +Create `src/vault/vault.css`. Copy the base styles (reset, colors, typography, scrollbar, buttons, inputs, form elements) from `src/popup/styles.css` — everything up through the component styles but NOT the popup-specific layout (body width/max-height). Then add the vault layout: + +```css +/* ---- Vault layout ---- */ + +body { + background: #0d1117; + color: #c9d1d9; + font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace; + font-size: 13px; + line-height: 1.5; + margin: 0; + height: 100vh; + overflow: hidden; +} + +#vault-app { + display: flex; + height: 100vh; +} + +.vault-sidebar { + width: 260px; + min-width: 260px; + border-right: 1px solid #21262d; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.vault-sidebar__header { + padding: 12px 16px; + border-bottom: 1px solid #21262d; + display: flex; + align-items: center; + gap: 8px; +} + +.vault-sidebar__search { + padding: 8px 12px; + border-bottom: 1px solid #21262d; +} + +.vault-sidebar__search input { + width: 100%; + background: #161b22; + border: 1px solid #30363d; + border-radius: 4px; + color: #c9d1d9; + padding: 6px 10px; + font-size: 12px; + font-family: inherit; +} + +.vault-sidebar__list { + flex: 1; + overflow-y: auto; +} + +.vault-sidebar__nav { + border-top: 1px solid #21262d; + padding: 8px 0; +} + +.vault-sidebar__nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + color: #8b949e; + font-size: 12px; + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; + font-family: inherit; +} + +.vault-sidebar__nav-item:hover { + color: #c9d1d9; + background: #161b22; +} + +.vault-pane { + flex: 1; + overflow-y: auto; + padding: 24px 32px; +} + +.vault-pane--empty { + display: flex; + align-items: center; + justify-content: center; + color: #484f58; + font-size: 14px; +} + +/* Sidebar item rows */ +.vault-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + cursor: pointer; + border-left: 2px solid transparent; + font-size: 12px; +} + +.vault-entry:hover { + background: #161b22; +} + +.vault-entry.selected { + background: #161b22; + border-left-color: #d2ab43; +} + +.vault-entry__title { + color: #c9d1d9; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vault-entry__meta { + color: #484f58; + font-size: 11px; +} + +/* Type group headers in sidebar */ +.vault-group-header { + padding: 12px 16px 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #484f58; +} +``` + +Include the shared component styles the pane needs (form groups, buttons, field rows, detail view, generator panel, attachments, etc.) — copy these from `src/popup/styles.css` so the pane renders correctly. Omit the popup-specific `.search-bar`, `.keyhints`, `.entry-list`, `.entry-row` classes (the vault sidebar uses its own classes above). + +- [ ] **Step 3: Create vault.ts scaffold with state management and hash routing** + +Create `src/vault/vault.ts`: + +```typescript +import type { Item, ItemId, ItemType, ManifestEntry, VaultSettings } from '../shared/types'; +import type { Request, Response } from '../shared/messages'; + +// --- Messaging --- + +function sendMessage(request: Request): Promise { + return new Promise((resolve) => { + chrome.runtime.sendMessage(request, (response: Response) => { + resolve(response); + }); + }); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// --- State --- + +type VaultView = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history'; + +interface VaultState { + view: VaultView; + entries: Array<[ItemId, ManifestEntry]>; + selectedId: ItemId | null; + selectedItem: Item | null; + searchQuery: string; + newType: ItemType | null; + error: string | null; + loading: boolean; + vaultSettings: VaultSettings | null; + historyItemId: ItemId | null; +} + +const state: VaultState = { + view: 'locked', + entries: [], + selectedId: null, + selectedItem: null, + searchQuery: '', + newType: null, + error: null, + loading: false, + vaultSettings: null, + historyItemId: null, +}; + +// --- Hash routing --- + +function parseHash(): { view: string; param?: string } { + const hash = window.location.hash.slice(1); // remove '#' + if (!hash) return { view: 'list' }; + const [view, param] = hash.split('/'); + return { view, param }; +} + +function setHash(view: string, param?: string): void { + window.location.hash = param ? `${view}/${param}` : view; +} + +// --- Render --- + +function render(): void { + const app = document.getElementById('vault-app'); + if (!app) return; + + if (state.view === 'locked') { + renderLockScreen(app); + return; + } + + renderShell(app); +} + +function renderLockScreen(app: HTMLElement): void { + app.innerHTML = ` +
+
+ +
relicario
+
two-factor vault
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} + + +
+
+ `; + + const input = document.getElementById('passphrase') as HTMLInputElement; + input?.focus(); + + const doUnlock = async (): Promise => { + const passphrase = input.value; + if (!passphrase) return; + state.loading = true; + state.error = null; + const resp = await sendMessage({ type: 'unlock', passphrase }); + state.loading = false; + if (resp.ok) { + await loadManifest(); + } else { + state.error = resp.error ?? 'Unlock failed'; + render(); + } + }; + + document.getElementById('unlock-btn')?.addEventListener('click', doUnlock); + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doUnlock(); + }); +} + +function renderShell(app: HTMLElement): void { + // Preserve sidebar if already rendered, only update pane + if (!app.querySelector('.vault-sidebar')) { + app.innerHTML = ` +
+
+ + relicario + + +
+ +
+
+ + + +
+
+
+ select an item +
+ `; + + wireSidebar(); + } + + renderSidebarList(); + renderPane(); +} + +function wireSidebar(): void { + const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; + searchInput?.addEventListener('input', () => { + state.searchQuery = searchInput.value; + renderSidebarList(); + }); + + document.getElementById('vault-lock-btn')?.addEventListener('click', async () => { + await sendMessage({ type: 'lock' }); + state.view = 'locked'; + state.selectedItem = null; + state.selectedId = null; + state.entries = []; + render(); + }); + + document.querySelectorAll('[data-nav]').forEach((btn) => { + btn.addEventListener('click', () => { + const nav = btn.dataset.nav as VaultView; + state.view = nav; + state.selectedId = null; + state.selectedItem = null; + setHash(nav); + renderPane(); + }); + }); + + document.addEventListener('keydown', (e) => { + if (e.key === '/' && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { + e.preventDefault(); + searchInput?.focus(); + } + }); +} + +function renderSidebarList(): void { + const listEl = document.getElementById('vault-item-list'); + if (!listEl) return; + + let filtered = state.entries; + if (state.searchQuery) { + const q = state.searchQuery.toLowerCase(); + filtered = filtered.filter(([, e]) => + e.title.toLowerCase().includes(q) || + (e.icon_hint ?? '').toLowerCase().includes(q) || + e.tags.some(t => t.toLowerCase().includes(q)) + ); + } + + // Group by type + const groups = new Map>(); + for (const entry of filtered) { + const type = entry[1].type; + if (!groups.has(type)) groups.set(type, []); + groups.get(type)!.push(entry); + } + + const typeOrder: string[] = ['login', 'secure_note', 'identity', 'card', 'key', 'totp', 'document']; + const typeLabel: Record = { + login: 'logins', secure_note: 'notes', identity: 'identities', + card: 'cards', key: 'keys', totp: 'totp', document: 'documents', + }; + const typeIcon: Record = { + login: '🔑', secure_note: '📝', identity: '🪪', + card: '💳', key: '🗝', totp: '⏱', document: '📄', + }; + + let html = ''; + for (const type of typeOrder) { + const items = groups.get(type); + if (!items || items.length === 0) continue; + html += `
${typeIcon[type] ?? ''} ${typeLabel[type] ?? type}
`; + for (const [id, e] of items) { + const sel = id === state.selectedId ? ' selected' : ''; + html += ` +
+ ${escapeHtml(e.title)} + +
`; + } + } + + if (!html) html = '
no items
'; + + listEl.innerHTML = html; + + listEl.querySelectorAll('.vault-entry').forEach((el) => { + el.addEventListener('click', async () => { + const id = el.dataset.id!; + state.selectedId = id; + state.loading = true; + renderSidebarList(); // update selection highlight + const resp = await sendMessage({ type: 'get_item', id }); + state.loading = false; + if (resp.ok) { + const data = resp.data as { item: Item }; + state.selectedItem = data.item; + state.view = 'detail'; + setHash('item', id); + renderPane(); + } + }); + }); +} + +function renderPane(): void { + const pane = document.getElementById('vault-pane'); + if (!pane) return; + pane.className = 'vault-pane'; + + switch (state.view) { + case 'detail': + if (state.selectedItem) { + renderPaneDetail(pane, state.selectedItem); + } else { + pane.className = 'vault-pane vault-pane--empty'; + pane.innerHTML = 'select an item'; + } + break; + case 'add': + renderPaneAdd(pane); + break; + case 'edit': + renderPaneEdit(pane); + break; + case 'trash': + renderPaneSection(pane, 'trash'); + break; + case 'devices': + renderPaneSection(pane, 'devices'); + break; + case 'settings': + renderPaneSection(pane, 'settings'); + break; + case 'settings-vault': + renderPaneSection(pane, 'settings-vault'); + break; + case 'field-history': + renderPaneSection(pane, 'field-history'); + break; + default: + pane.className = 'vault-pane vault-pane--empty'; + pane.innerHTML = 'select an item'; + } +} + +// --- Pane renderers --- +// These import the existing popup component renderers and pass the pane element. +// The popup renderers write to whatever element they receive via `app`. + +async function renderPaneDetail(pane: HTMLElement, item: Item): Promise { + // Dynamic import to reuse popup detail renderers + const { renderItemDetail } = await import('../popup/components/item-detail'); + renderItemDetail(pane); +} + +function renderPaneAdd(pane: HTMLElement): void { + import('../popup/components/item-form').then(({ renderItemForm }) => { + renderItemForm(pane, 'add'); + }); +} + +function renderPaneEdit(pane: HTMLElement): void { + import('../popup/components/item-form').then(({ renderItemForm }) => { + renderItemForm(pane, 'edit'); + }); +} + +async function renderPaneSection(pane: HTMLElement, section: string): Promise { + switch (section) { + case 'trash': { + const { renderTrash } = await import('../popup/components/trash'); + renderTrash(pane); + break; + } + case 'devices': { + const { renderDevices } = await import('../popup/components/devices'); + renderDevices(pane); + break; + } + case 'settings': { + const { renderSettings } = await import('../popup/components/settings'); + renderSettings(pane); + break; + } + case 'settings-vault': { + const { renderVaultSettings } = await import('../popup/components/settings-vault'); + renderVaultSettings(pane); + break; + } + case 'field-history': { + const { renderFieldHistory } = await import('../popup/components/field-history'); + renderFieldHistory(pane); + break; + } + } +} + +// --- Init --- + +async function loadManifest(): Promise { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + state.entries = data.items; + } + const vsResp = await sendMessage({ type: 'get_vault_settings' }); + if (vsResp.ok) { + state.vaultSettings = (vsResp.data as { settings: VaultSettings }).settings; + } + state.view = 'list'; + render(); + + // Handle deep link from hash + const { view, param } = parseHash(); + if (view === 'item' && param) { + const resp = await sendMessage({ type: 'get_item', id: param }); + if (resp.ok) { + state.selectedId = param; + state.selectedItem = (resp.data as { item: Item }).item; + state.view = 'detail'; + renderSidebarList(); + renderPane(); + } + } else if (view === 'add' && param) { + state.newType = param as ItemType; + state.view = 'add'; + renderPane(); + } else if (view === 'trash' || view === 'devices' || view === 'settings') { + state.view = view as VaultView; + renderPane(); + } +} + +document.addEventListener('DOMContentLoaded', async () => { + const resp = await sendMessage({ type: 'is_unlocked' }); + if (resp.ok && (resp.data as { unlocked: boolean }).unlocked) { + await loadManifest(); + } else { + state.view = 'locked'; + render(); + } +}); + +// Listen for session expiry +chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'session_expired') { + state.view = 'locked'; + state.error = null; + state.selectedItem = null; + state.selectedId = null; + state.entries = []; + render(); + } +}); + +// Hash change navigation +window.addEventListener('hashchange', () => { + const { view, param } = parseHash(); + if (view === 'trash' || view === 'devices' || view === 'settings') { + state.view = view as VaultView; + state.selectedId = null; + state.selectedItem = null; + renderPane(); + } +}); +``` + +- [ ] **Step 4: Add vault entry point to webpack.config.js** + +In `webpack.config.js`, add to the `entry` object: + +```javascript +vault: './src/vault/vault.ts', +``` + +In the CopyPlugin patterns array, add: + +```javascript +{ from: 'src/vault/vault.html', to: 'vault.html' }, +{ from: 'src/vault/vault.css', to: 'vault.css' }, +``` + +- [ ] **Step 5: Update manifest.json with commands and vault.html access** + +In `manifest.json`, add a `commands` section: + +```json +"commands": { + "open-vault": { + "description": "Open relicario vault" + } +} +``` + +Note: no `suggested_key` — the user configures it in `chrome://extensions/shortcuts`. + +Add the commands listener in `src/service-worker/index.ts`: + +```typescript +chrome.commands.onCommand.addListener((command) => { + if (command === 'open-vault') { + chrome.tabs.create({ url: chrome.runtime.getURL('vault.html') }); + } +}); +``` + +- [ ] **Step 6: Build and verify** + +Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -10` +Expected: Build succeeds with vault.js in output, vault.html and vault.css copied to dist/. + +Verify files exist: +```bash +ls dist/vault.html dist/vault.css dist/vault.js +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/vault/ webpack.config.js manifest.json src/service-worker/index.ts +git commit -m "feat(ext/vault): scaffold vault.html tab with sidebar+pane layout and hash routing" +``` + +--- + +### Task 4: Resolve Shared State — Vault Tab Uses Popup's State Functions + +**Files:** +- Modify: `src/popup/popup.ts` +- Modify: `src/vault/vault.ts` +- Modify: `src/popup/components/item-detail.ts` (if it calls navigate/setState) +- Modify: any popup component that calls `navigate`, `setState`, `getState`, `sendMessage` + +The popup components (item-detail, item-form, trash, devices, settings, etc.) all import from `../popup` (i.e., `src/popup/popup.ts`) for `navigate`, `setState`, `getState`, `sendMessage`, `escapeHtml`. When the vault tab dynamically imports these components, those imports resolve to the popup's module — but in the vault's webpack bundle, `popup.ts` won't be initialized (no `DOMContentLoaded`, wrong state). + +The fix: extract the shared functions into a new `src/shared/state.ts` that both popup and vault initialize with their own state/render callbacks. + +- [ ] **Step 1: Create src/shared/state.ts** + +```typescript +import type { Request, Response } from './messages'; + +export interface StateHost { + getState(): any; + setState(partial: any): void; + navigate(view: string, extras?: any): void; + sendMessage(request: Request): Promise; + escapeHtml(s: string): string; + popOutToTab(): void; + isInTab(): boolean; + openVaultTab(hash?: string): void; +} + +let host: StateHost | null = null; + +export function registerHost(h: StateHost): void { host = h; } + +export function getState(): any { + if (!host) throw new Error('No state host registered'); + return host.getState(); +} + +export function setState(partial: any): void { + if (!host) throw new Error('No state host registered'); + host.setState(partial); +} + +export function navigate(view: string, extras?: any): void { + if (!host) throw new Error('No state host registered'); + host.navigate(view, extras); +} + +export function sendMessage(request: Request): Promise { + if (!host) throw new Error('No state host registered'); + return host.sendMessage(request); +} + +export function escapeHtml(s: string): string { + if (!host) throw new Error('No state host registered'); + return host.escapeHtml(s); +} + +export function popOutToTab(): void { + if (!host) throw new Error('No state host registered'); + host.popOutToTab(); +} + +export function isInTab(): boolean { + if (!host) return false; + return host.isInTab(); +} + +export function openVaultTab(hash?: string): void { + if (!host) throw new Error('No state host registered'); + host.openVaultTab(hash); +} +``` + +- [ ] **Step 2: Update popup.ts to register as host** + +In `src/popup/popup.ts`, at the end of the existing exports section (before the `DOMContentLoaded` listener), call `registerHost`: + +```typescript +import { registerHost } from '../shared/state'; + +registerHost({ + getState: () => currentState, + setState, + navigate, + sendMessage, + escapeHtml, + popOutToTab, + isInTab, + openVaultTab, +}); +``` + +Keep the existing exports — the popup's own code can still import directly from `./popup`. The state host is for cross-bundle component access. + +- [ ] **Step 3: Update vault.ts to register as host** + +In `src/vault/vault.ts`, register its own state host before calling `render()`: + +```typescript +import { registerHost } from '../shared/state'; + +registerHost({ + getState: () => state, + setState: (partial: Partial) => { + Object.assign(state, partial); + render(); + }, + navigate: (view: string, extras?: Partial) => { + Object.assign(state, { view, error: null, loading: false, ...extras }); + if (view === 'list') { + state.selectedId = null; + state.selectedItem = null; + } + setHash(view); + render(); + }, + sendMessage, + escapeHtml, + popOutToTab: () => {}, // no-op, already in tab + isInTab: () => true, + openVaultTab: () => {}, // no-op, already in vault +}); +``` + +- [ ] **Step 4: Update all popup components to import from shared/state** + +In every file under `src/popup/components/` that imports from `../popup` or `../../popup`, change the import to use `../../shared/state` (or `../shared/state` depending on depth). + +Files to update (all imports of `navigate`, `setState`, `getState`, `sendMessage`, `escapeHtml`, `popOutToTab`, `isInTab`, `openVaultTab`): + +- `src/popup/components/item-list.ts` +- `src/popup/components/item-detail.ts` +- `src/popup/components/item-form.ts` +- `src/popup/components/unlock.ts` +- `src/popup/components/settings.ts` +- `src/popup/components/settings-vault.ts` +- `src/popup/components/trash.ts` +- `src/popup/components/devices.ts` +- `src/popup/components/field-history.ts` +- `src/popup/components/fields.ts` +- `src/popup/components/generator-panel.ts` +- `src/popup/components/attachments-disclosure.ts` +- `src/popup/components/types/login.ts` +- `src/popup/components/types/secure-note.ts` +- `src/popup/components/types/identity.ts` +- `src/popup/components/types/card.ts` +- `src/popup/components/types/key.ts` +- `src/popup/components/types/totp.ts` +- `src/popup/components/types/document.ts` + +For each file, replace: +```typescript +import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../popup'; +``` +with: +```typescript +import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../shared/state'; +``` + +Adjust relative path depth accordingly (components in `types/` need `../../../shared/state`; components directly in `components/` need `../../shared/state`). + +Note: `popup.ts` itself should NOT change its own internal function definitions — it still defines `setState`, `navigate`, etc. locally. It just also calls `registerHost` to expose them to the shared state module. + +- [ ] **Step 4b: Update test mocks to match new import paths** + +Test files under `src/popup/components/__tests__/` and `src/popup/components/types/__tests__/` mock `../../popup` or `../../../popup`. Update all `vi.mock('../../popup', ...)` calls to `vi.mock('../../shared/state', ...)` (and similarly for `../../../popup` → `../../../shared/state`). The mock shape stays the same — just the path changes. + +Test files to update: +- `src/popup/components/__tests__/attachments-disclosure.test.ts` +- `src/popup/components/__tests__/devices.test.ts` +- `src/popup/components/__tests__/field-history.test.ts` +- `src/popup/components/__tests__/fields.test.ts` +- `src/popup/components/__tests__/generator-panel.test.ts` +- `src/popup/components/__tests__/sections-editor.test.ts` +- `src/popup/components/__tests__/sections-render.test.ts` +- `src/popup/components/__tests__/settings-vault.test.ts` +- `src/popup/components/__tests__/trash.test.ts` +- `src/popup/components/types/__tests__/card.save.test.ts` +- `src/popup/components/types/__tests__/document.save.test.ts` +- `src/popup/components/types/__tests__/identity.save.test.ts` +- `src/popup/components/types/__tests__/key.save.test.ts` +- `src/popup/components/types/__tests__/sections-save.test.ts` +- `src/popup/components/types/__tests__/secure-note.save.test.ts` +- `src/popup/components/types/__tests__/totp.save.test.ts` + +- [ ] **Step 5: Update vault.ts pane renderers to drop dynamic import wrappers** + +Since the components now import from `shared/state` (which vault.ts has registered as host), the vault can import them directly. Simplify the pane render functions in `vault.ts`: + +```typescript +import { renderItemDetail } from '../popup/components/item-detail'; +import { renderItemForm } from '../popup/components/item-form'; +import { renderTrash } from '../popup/components/trash'; +import { renderDevices } from '../popup/components/devices'; +import { renderSettings } from '../popup/components/settings'; +import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault'; +import { renderFieldHistory } from '../popup/components/field-history'; +``` + +Replace the async pane renderers with direct calls: + +```typescript +function renderPane(): void { + const pane = document.getElementById('vault-pane'); + if (!pane) return; + pane.className = 'vault-pane'; + + switch (state.view) { + case 'detail': + if (state.selectedItem) { + renderItemDetail(pane); + } else { + pane.className = 'vault-pane vault-pane--empty'; + pane.innerHTML = 'select an item'; + } + break; + case 'add': + renderItemForm(pane, 'add'); + break; + case 'edit': + renderItemForm(pane, 'edit'); + break; + case 'trash': + renderTrash(pane); + break; + case 'devices': + renderDevices(pane); + break; + case 'settings': + renderSettings(pane); + break; + case 'settings-vault': + renderVaultSettingsView(pane); + break; + case 'field-history': + renderFieldHistory(pane); + break; + default: + pane.className = 'vault-pane vault-pane--empty'; + pane.innerHTML = 'select an item'; + } +} +``` + +Remove the old `renderPaneDetail`, `renderPaneAdd`, `renderPaneEdit`, `renderPaneSection` functions. + +- [ ] **Step 6: Add "+ new" button to sidebar header** + +In the sidebar header of `vault.ts`, add a new button: + +```html + +``` + +Wire it: + +```typescript +document.getElementById('vault-new-btn')?.addEventListener('click', () => { + state.newType = null; + state.view = 'add'; + setHash('add'); + renderPane(); +}); +``` + +- [ ] **Step 7: Build and test** + +Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -10` +Expected: Build succeeds. Both popup.js and vault.js compile. + +Run: `bun test 2>&1 | tail -20` +Expected: Existing tests still pass (components import from shared/state, which delegates to registered host — tests mock the functions they need). + +- [ ] **Step 8: Commit** + +```bash +git add src/shared/state.ts src/vault/vault.ts src/popup/popup.ts src/popup/components/ +git commit -m "feat(ext): shared state host so vault tab reuses popup components" +``` + +--- + +### Task 5: Device Settings — Session Timeout Config UI + +**Files:** +- Modify: `src/popup/components/settings.ts` +- Modify: `src/vault/vault.ts` (settings nav already wired) + +- [ ] **Step 1: Read current settings.ts to understand structure** + +Read `src/popup/components/settings.ts` to see how the existing settings view is structured and where to add device settings. + +- [ ] **Step 2: Add session timeout UI to settings view** + +In `src/popup/components/settings.ts`, in the `renderSettings` function, add a "device settings" section at the top (before vault settings link): + +```html +
+

device

+
+ +
+ + +
+
+
+``` + +Fetch the current config on render: + +```typescript +const configResp = await sendMessage({ type: 'get_session_config' }); +const config = configResp.ok + ? (configResp.data as { config: SessionTimeoutConfig }).config + : { mode: 'inactivity' as const, minutes: 15 }; +``` + +Wire the change handlers: + +```typescript +document.getElementById('timeout-mode')?.addEventListener('change', async (e) => { + const mode = (e.target as HTMLSelectElement).value; + const minutesSelect = document.getElementById('timeout-minutes') as HTMLSelectElement; + if (mode === 'every_time') { + minutesSelect.disabled = true; + await sendMessage({ type: 'update_session_config', config: { mode: 'every_time' } }); + } else { + minutesSelect.disabled = false; + const minutes = parseInt(minutesSelect.value, 10); + await sendMessage({ type: 'update_session_config', config: { mode: 'inactivity', minutes } }); + } +}); + +document.getElementById('timeout-minutes')?.addEventListener('change', async (e) => { + const minutes = parseInt((e.target as HTMLSelectElement).value, 10); + await sendMessage({ type: 'update_session_config', config: { mode: 'inactivity', minutes } }); +}); +``` + +Import `SessionTimeoutConfig` from `../../shared/messages`. + +Note: `renderSettings` needs to become `async` for the `sendMessage` call. Update the function signature and the caller in popup.ts render switch. + +- [ ] **Step 3: Build and test** + +Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5` +Expected: Build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/popup/components/settings.ts src/popup/popup.ts +git commit -m "feat(ext/settings): add session timeout config to device settings" +``` + +--- + +### Task 6: Router — Allow vault.html as Trusted Sender + +**Files:** +- Modify: `src/service-worker/router/index.ts` + +- [ ] **Step 1: Update router to recognize vault.html** + +In `src/service-worker/router/index.ts`, add vault URL detection: + +```typescript +const vaultUrl = chrome.runtime.getURL('vault.html'); +``` + +Update `isPopup` to include vault: + +```typescript +const isPopup = senderUrl.startsWith(popupUrl) || senderUrl.startsWith(vaultUrl); +``` + +- [ ] **Step 2: Build** + +Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5` +Expected: Build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/service-worker/router/index.ts +git commit -m "fix(ext/router): allow vault.html as trusted sender for popup-only messages" +``` + +--- + +### Task 7: Manual Browser Testing + +- [ ] **Step 1: Build the extension** + +```bash +cd /home/alee/Sources/relicario/extension && bun run build +``` + +- [ ] **Step 2: Reload extension in Chrome** + +Go to `chrome://extensions`, click reload on relicario. + +- [ ] **Step 3: Test popup basics** + +- Open popup, verify search doesn't auto-focus +- Type `/` to focus search, verify text goes forward +- Use arrow keys and Enter to open an item +- Verify Shift+F opens vault tab +- Verify "⤴" button in toolbar opens vault tab + +- [ ] **Step 4: Test vault tab** + +- Verify vault tab shows lock screen if locked +- Unlock with passphrase +- Verify sidebar shows items grouped by type +- Click an item — detail appears in right pane +- Click "+ new" — type selection appears in pane +- Select a type — form appears in pane +- Click trash/devices/settings in sidebar nav +- Use `/` to focus sidebar search +- Verify URL hash updates as you navigate + +- [ ] **Step 5: Test session timeout** + +- Go to settings in either popup or vault tab +- Set timeout to 5 minutes +- Wait 5 minutes (or temporarily set to a short value for testing) +- Verify both popup and vault tab show lock screen + +- [ ] **Step 6: Test popup → vault navigation** + +- Open popup, view an item, click popout button → verify vault tab opens with that item selected +- Open popup, click "+ new", select card → verify vault tab opens with card form +- Open popup lock screen, click "open vault" → verify vault tab opens + +- [ ] **Step 7: Fix any issues found, rebuild, retest, commit**