7 tasks: session timer, popup navigation, vault scaffold, shared state host, device settings, router fix, manual testing. Co-Authored-By: Claude <noreply@anthropic.com>
1442 lines
42 KiB
Markdown
1442 lines
42 KiB
Markdown
# 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<typeof setTimeout> | 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
|
|
<button class="btn" id="open-vault-btn">open vault</button>
|
|
```
|
|
|
|
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
|
|
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">⤴</button>
|
|
```
|
|
|
|
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
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>relicario — vault</title>
|
|
<link rel="stylesheet" href="vault.css">
|
|
</head>
|
|
<body>
|
|
<div id="vault-app"></div>
|
|
<script src="vault.js"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **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<Response> {
|
|
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, '"')
|
|
.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 = `
|
|
<div style="display:flex; align-items:center; justify-content:center; height:100vh; width:100%;">
|
|
<div style="text-align:center; width:320px;">
|
|
<img class="brand-logo" src="icons/icon128.png" alt="relicario">
|
|
<div class="brand" style="margin-bottom:4px;">relicario</div>
|
|
<div class="muted" style="margin-bottom:20px;">two-factor vault</div>
|
|
${state.error ? `<div class="error" style="margin-bottom:12px;">${escapeHtml(state.error)}</div>` : ''}
|
|
<input type="password" id="passphrase" placeholder="passphrase"
|
|
style="width:100%; margin-bottom:12px;">
|
|
<button class="btn btn-primary" id="unlock-btn" style="width:100%;">unlock</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const input = document.getElementById('passphrase') as HTMLInputElement;
|
|
input?.focus();
|
|
|
|
const doUnlock = async (): Promise<void> => {
|
|
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 = `
|
|
<div class="vault-sidebar">
|
|
<div class="vault-sidebar__header">
|
|
<img src="icons/icon48.png" alt="" style="width:24px; height:24px;">
|
|
<span class="brand" style="font-size:13px;">relicario</span>
|
|
<span style="flex:1;"></span>
|
|
<button class="btn" id="vault-lock-btn" style="font-size:11px;">lock</button>
|
|
</div>
|
|
<div class="vault-sidebar__search">
|
|
<input type="text" id="vault-search" placeholder="/ search...">
|
|
</div>
|
|
<div class="vault-sidebar__list" id="vault-item-list"></div>
|
|
<div class="vault-sidebar__nav">
|
|
<button class="vault-sidebar__nav-item" data-nav="trash">🗑 trash</button>
|
|
<button class="vault-sidebar__nav-item" data-nav="devices">📱 devices</button>
|
|
<button class="vault-sidebar__nav-item" data-nav="settings">⚙ settings</button>
|
|
</div>
|
|
</div>
|
|
<div class="vault-pane vault-pane--empty" id="vault-pane">
|
|
select an item
|
|
</div>
|
|
`;
|
|
|
|
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<HTMLButtonElement>('[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<string, Array<[ItemId, ManifestEntry]>>();
|
|
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<string, string> = {
|
|
login: 'logins', secure_note: 'notes', identity: 'identities',
|
|
card: 'cards', key: 'keys', totp: 'totp', document: 'documents',
|
|
};
|
|
const typeIcon: Record<string, string> = {
|
|
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 += `<div class="vault-group-header">${typeIcon[type] ?? ''} ${typeLabel[type] ?? type}</div>`;
|
|
for (const [id, e] of items) {
|
|
const sel = id === state.selectedId ? ' selected' : '';
|
|
html += `
|
|
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
|
|
<span class="vault-entry__title">${escapeHtml(e.title)}</span>
|
|
<span class="vault-entry__meta">${escapeHtml(e.icon_hint ?? '')}</span>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
if (!html) html = '<div style="padding:24px 16px; color:#484f58; text-align:center;">no items</div>';
|
|
|
|
listEl.innerHTML = html;
|
|
|
|
listEl.querySelectorAll<HTMLElement>('.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<void> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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<Response>;
|
|
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<Response> {
|
|
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<VaultState>) => {
|
|
Object.assign(state, partial);
|
|
render();
|
|
},
|
|
navigate: (view: string, extras?: Partial<VaultState>) => {
|
|
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
|
|
<button class="btn" id="vault-new-btn" style="font-size:11px;">+ new</button>
|
|
```
|
|
|
|
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
|
|
<div class="settings-section">
|
|
<h4 class="settings-heading">device</h4>
|
|
<div class="form-group">
|
|
<label class="label">auto-lock</label>
|
|
<div class="inline-row" style="gap:8px;">
|
|
<select id="timeout-mode" style="flex:1;">
|
|
<option value="inactivity"${config.mode === 'inactivity' ? ' selected' : ''}>after inactivity</option>
|
|
<option value="every_time"${config.mode === 'every_time' ? ' selected' : ''}>every time</option>
|
|
</select>
|
|
<select id="timeout-minutes" style="width:80px;"${config.mode === 'every_time' ? ' disabled' : ''}>
|
|
${[5, 15, 30, 60].map(m => `<option value="${m}"${config.mode === 'inactivity' && config.minutes === m ? ' selected' : ''}>${m} min</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
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**
|