13 tasks, bottom-up layering: 1. WASM bindings (generate_device_keypair, get_field_history) 2. Shared types + messages 3-5. Service worker handlers (devices, trash, field history) 6-8. Popup screens (trash, devices, field-history) 9. Item detail "View history" link 10. Vault settings attachment cap 11. Popup navigation wiring 12. Setup wizard device name step 13. Manual browser testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2306 lines
66 KiB
Markdown
2306 lines
66 KiB
Markdown
# Plan 1C-γ₂ Implementation Plan — Device registration + Trash + Field history + Attachment caps
|
||
|
||
> **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 device registration during setup wizard, device management UI, trash view with restore/purge (including orphan blob cleanup), per-item field history view, and attachment-cap configuration in vault settings.
|
||
|
||
**Architecture:** Bottom-up by layer. WASM bindings first (ed25519 keypair generation, field history extraction), then shared types, then service worker handlers, then popup screens. Setup wizard changes come last as the riskiest change.
|
||
|
||
**Tech Stack:** TypeScript, vitest + happy-dom (popup tests), webpack, Rust core via WASM. ed25519-dalek for device keypairs (already in relicario-core).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-26-relicario-extension-1c-gamma2-design.md`
|
||
|
||
---
|
||
|
||
## File overview
|
||
|
||
**WASM:**
|
||
- `crates/relicario-wasm/src/lib.rs` — add `generate_device_keypair`, `get_field_history` (Task 1)
|
||
|
||
**Shared types & messages:**
|
||
- `extension/src/shared/types.ts` — add `Device`, `FieldHistory`, `FieldHistoryViewEntry` (Task 2)
|
||
- `extension/src/shared/messages.ts` — add 8 message types (Task 2)
|
||
|
||
**Service worker:**
|
||
- `extension/src/service-worker/devices.ts` — NEW: device CRUD helpers (Task 3)
|
||
- `extension/src/service-worker/vault.ts` — add `listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash` (Task 4)
|
||
- `extension/src/service-worker/router/popup-only.ts` — add handlers for all 8 messages (Tasks 3, 4, 5)
|
||
|
||
**Popup:**
|
||
- `extension/src/popup/components/trash.ts` — NEW (Task 6)
|
||
- `extension/src/popup/components/devices.ts` — NEW (Task 7)
|
||
- `extension/src/popup/components/field-history.ts` — NEW (Task 8)
|
||
- `extension/src/popup/components/types/*.ts` — add "View history" link to detail views (Task 9)
|
||
- `extension/src/popup/components/settings-vault.ts` — add attachment caps section (Task 10)
|
||
- `extension/src/popup/popup.ts` — add navigation for trash, devices, field-history (Task 11)
|
||
- `extension/src/setup/setup.ts` — add device name step (Task 12)
|
||
|
||
Working dir: `/home/alee/Sources/relicario`. Branch: main. Direct-to-main per project convention. Do NOT push.
|
||
|
||
---
|
||
|
||
## Task 1: WASM bindings — `generate_device_keypair` + `get_field_history`
|
||
|
||
**Files:**
|
||
- Modify: `crates/relicario-wasm/Cargo.toml`
|
||
- Modify: `crates/relicario-wasm/src/lib.rs`
|
||
|
||
- [ ] **Step 1: Add ed25519-dalek + base64 to WASM Cargo.toml**
|
||
|
||
Edit `crates/relicario-wasm/Cargo.toml` — add after the `getrandom` line in `[dependencies]`:
|
||
|
||
```toml
|
||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||
base64 = "0.22"
|
||
```
|
||
|
||
- [ ] **Step 2: Add `generate_device_keypair` to lib.rs**
|
||
|
||
Edit `crates/relicario-wasm/src/lib.rs` — add these imports near the top after the existing `use` statements:
|
||
|
||
```rust
|
||
use ed25519_dalek::SigningKey;
|
||
use base64::Engine;
|
||
```
|
||
|
||
Then append this function after `rate_passphrase`:
|
||
|
||
```rust
|
||
/// Generate an ed25519 keypair for device registration.
|
||
/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." }
|
||
#[wasm_bindgen]
|
||
pub fn generate_device_keypair() -> Result<JsValue, JsError> {
|
||
let mut rng = rand::thread_rng();
|
||
let signing_key = SigningKey::generate(&mut rng);
|
||
let verifying_key = signing_key.verifying_key();
|
||
|
||
let public_hex = hex::encode(verifying_key.as_bytes());
|
||
let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.as_bytes());
|
||
|
||
js_value_for(&serde_json::json!({
|
||
"public_key_hex": public_hex,
|
||
"private_key_base64": private_b64,
|
||
}))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add hex crate to Cargo.toml**
|
||
|
||
The `hex` crate is needed. Edit `crates/relicario-wasm/Cargo.toml` — add:
|
||
|
||
```toml
|
||
hex = "0.4"
|
||
```
|
||
|
||
- [ ] **Step 4: Add rand crate with getrandom backend**
|
||
|
||
Edit `crates/relicario-wasm/Cargo.toml` — add:
|
||
|
||
```toml
|
||
rand = "0.8"
|
||
```
|
||
|
||
- [ ] **Step 5: Add `get_field_history` to lib.rs**
|
||
|
||
Append after `generate_device_keypair`:
|
||
|
||
```rust
|
||
use relicario_core::{Item, FieldKind};
|
||
|
||
/// Extract field history from a decrypted item JSON.
|
||
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
|
||
#[wasm_bindgen]
|
||
pub fn get_field_history(item_json: &str) -> Result<JsValue, JsError> {
|
||
let item: Item = serde_json::from_str(item_json)
|
||
.map_err(|e| JsError::new(&format!("item json: {e}")))?;
|
||
|
||
let mut results = Vec::new();
|
||
|
||
// Collect tracked fields from core + sections
|
||
let tracked_kinds = [FieldKind::Password, FieldKind::Concealed, FieldKind::Totp];
|
||
|
||
// Check core fields based on item type
|
||
if let Some(password) = match &item.core {
|
||
relicario_core::ItemCore::Login(c) => c.password.as_ref(),
|
||
_ => None,
|
||
} {
|
||
// Find the field_id for password in field_history
|
||
for (field_id, entries) in &item.field_history {
|
||
// Password field in LoginCore — check if history exists
|
||
if !entries.is_empty() {
|
||
results.push(serde_json::json!({
|
||
"field_id": field_id.as_str(),
|
||
"field_name": "password",
|
||
"current_value": password,
|
||
"entries": entries.iter().map(|e| serde_json::json!({
|
||
"value": e.value,
|
||
"changed_at": e.replaced_at,
|
||
})).collect::<Vec<_>>(),
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check section fields for tracked kinds
|
||
for section in &item.sections {
|
||
for field in §ion.fields {
|
||
if tracked_kinds.contains(&field.kind) {
|
||
if let Some(entries) = item.field_history.get(&field.id) {
|
||
if !entries.is_empty() {
|
||
let current = match &field.value {
|
||
relicario_core::FieldValue::Password(v) => v.expose_secret().to_string(),
|
||
relicario_core::FieldValue::Concealed(v) => v.expose_secret().to_string(),
|
||
_ => String::new(),
|
||
};
|
||
results.push(serde_json::json!({
|
||
"field_id": field.id.as_str(),
|
||
"field_name": &field.label,
|
||
"current_value": current,
|
||
"entries": entries.iter().map(|e| serde_json::json!({
|
||
"value": e.value,
|
||
"changed_at": e.replaced_at,
|
||
})).collect::<Vec<_>>(),
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
js_value_for(&results)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify WASM build**
|
||
|
||
Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10`
|
||
|
||
Expected: Build succeeds (warnings OK).
|
||
|
||
- [ ] **Step 7: Rebuild WASM bundle for extension**
|
||
|
||
Run: `cd extension && bun run build:wasm 2>&1 | tail -5`
|
||
|
||
(If `build:wasm` doesn't exist, run: `wasm-pack build ../crates/relicario-wasm --target web --out-dir ../extension/src/wasm`)
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add crates/relicario-wasm/
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(wasm): add generate_device_keypair + get_field_history bindings
|
||
|
||
generate_device_keypair returns ed25519 keypair as JSON with hex pubkey
|
||
and base64 private key. get_field_history extracts tracked field history
|
||
from a decrypted item for the popup's history view.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Shared types + message types
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/shared/types.ts`
|
||
- Modify: `extension/src/shared/messages.ts`
|
||
|
||
- [ ] **Step 1: Add Device type to types.ts**
|
||
|
||
Edit `extension/src/shared/types.ts` — add after the `AttachmentSummary` interface (around line 145):
|
||
|
||
```typescript
|
||
// --- Devices ---
|
||
|
||
export interface Device {
|
||
name: string;
|
||
public_key: string; // hex-encoded ed25519 pubkey
|
||
added_at: number; // unix timestamp
|
||
}
|
||
|
||
// --- Field history view ---
|
||
|
||
export interface FieldHistoryViewEntry {
|
||
value: string;
|
||
changed_at: number;
|
||
}
|
||
|
||
export interface FieldHistoryView {
|
||
field_id: string;
|
||
field_name: string;
|
||
current_value: string;
|
||
entries: FieldHistoryViewEntry[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add message types to messages.ts**
|
||
|
||
Edit `extension/src/shared/messages.ts` — add to the `PopupMessage` union (after `download_attachment`):
|
||
|
||
```typescript
|
||
| { type: 'list_devices' }
|
||
| { type: 'add_device'; name: string; public_key: string }
|
||
| { type: 'revoke_device'; name: string }
|
||
| { type: 'list_trashed' }
|
||
| { type: 'restore_item'; id: ItemId }
|
||
| { type: 'purge_item'; id: ItemId }
|
||
| { type: 'purge_all_trash' }
|
||
| { type: 'get_field_history'; id: ItemId };
|
||
```
|
||
|
||
- [ ] **Step 3: Add to POPUP_ONLY_TYPES set**
|
||
|
||
Edit `extension/src/shared/messages.ts` — find `POPUP_ONLY_TYPES` and add the new types:
|
||
|
||
```typescript
|
||
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||
'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
|
||
'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
|
||
'save_setup', 'rate_passphrase', 'generate_password', 'generate_passphrase',
|
||
'fill_credentials',
|
||
'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_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
|
||
'get_field_history',
|
||
] as PopupMessage['type'][]);
|
||
```
|
||
|
||
- [ ] **Step 4: Add response type helpers**
|
||
|
||
Edit `extension/src/shared/messages.ts` — add after `DownloadAttachmentResponse`:
|
||
|
||
```typescript
|
||
export interface ListDevicesResponse extends Extract<Response, { ok: true }> {
|
||
data: { devices: Device[] };
|
||
}
|
||
|
||
export interface ListTrashedResponse extends Extract<Response, { ok: true }> {
|
||
data: { items: Array<[ItemId, ManifestEntry]> };
|
||
}
|
||
|
||
export interface PurgeAllTrashResponse extends Extract<Response, { ok: true }> {
|
||
data: { itemCount: number; orphanCount: number };
|
||
}
|
||
|
||
export interface FieldHistoryResponse extends Extract<Response, { ok: true }> {
|
||
data: { history: FieldHistoryView[] };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add Device import to messages.ts**
|
||
|
||
Edit `extension/src/shared/messages.ts` — update the import at the top:
|
||
|
||
```typescript
|
||
import type {
|
||
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
||
DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, Device,
|
||
FieldHistoryView,
|
||
} from './types';
|
||
```
|
||
|
||
- [ ] **Step 6: Verify type-check**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit 2>&1 | tail -5`
|
||
|
||
Expected: Zero errors.
|
||
|
||
- [ ] **Step 7: Run vitest**
|
||
|
||
Run: `cd extension && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/shared/types.ts extension/src/shared/messages.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/shared): add Device + FieldHistory types + 8 new message types
|
||
|
||
Device: name, public_key (hex), added_at.
|
||
FieldHistoryView: field_id, field_name, current_value, entries[].
|
||
Messages: list_devices, add_device, revoke_device, list_trashed,
|
||
restore_item, purge_item, purge_all_trash, get_field_history.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Service worker — devices.ts + handlers
|
||
|
||
**Files:**
|
||
- Create: `extension/src/service-worker/devices.ts`
|
||
- Modify: `extension/src/service-worker/router/popup-only.ts`
|
||
- Create: `extension/src/service-worker/__tests__/devices.test.ts`
|
||
|
||
- [ ] **Step 1: Create devices.ts**
|
||
|
||
Create `extension/src/service-worker/devices.ts`:
|
||
|
||
```typescript
|
||
/// Device management — reads/writes .relicario/devices.json
|
||
|
||
import type { GitHost } from './git-host';
|
||
import type { Device } from '../shared/types';
|
||
|
||
const DEVICES_PATH = '.relicario/devices.json';
|
||
|
||
interface DevicesFile {
|
||
devices: Device[];
|
||
}
|
||
|
||
export async function readDevices(gitHost: GitHost): Promise<Device[]> {
|
||
try {
|
||
const raw = await gitHost.readFile(DEVICES_PATH);
|
||
const text = new TextDecoder().decode(raw);
|
||
const parsed: DevicesFile = JSON.parse(text);
|
||
return parsed.devices ?? [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export async function writeDevices(
|
||
gitHost: GitHost,
|
||
devices: Device[],
|
||
message: string,
|
||
): Promise<void> {
|
||
const content: DevicesFile = { devices };
|
||
const bytes = new TextEncoder().encode(JSON.stringify(content, null, 2));
|
||
await gitHost.writeFile(DEVICES_PATH, bytes, message);
|
||
}
|
||
|
||
export async function addDevice(
|
||
gitHost: GitHost,
|
||
device: Device,
|
||
): Promise<void> {
|
||
const existing = await readDevices(gitHost);
|
||
if (existing.some((d) => d.name === device.name)) {
|
||
throw new Error(`device '${device.name}' already exists`);
|
||
}
|
||
existing.push(device);
|
||
await writeDevices(gitHost, existing, `device: add ${device.name}`);
|
||
}
|
||
|
||
export async function revokeDevice(
|
||
gitHost: GitHost,
|
||
name: string,
|
||
): Promise<void> {
|
||
const existing = await readDevices(gitHost);
|
||
const filtered = existing.filter((d) => d.name !== name);
|
||
if (filtered.length === existing.length) {
|
||
throw new Error(`device '${name}' not found`);
|
||
}
|
||
await writeDevices(gitHost, filtered, `device: revoke ${name}`);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add device handlers to popup-only.ts**
|
||
|
||
Edit `extension/src/service-worker/router/popup-only.ts` — add import at top:
|
||
|
||
```typescript
|
||
import * as devices from '../devices';
|
||
```
|
||
|
||
Then add these cases to the switch statement before the default/closing brace:
|
||
|
||
```typescript
|
||
case 'list_devices': {
|
||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
const list = await devices.readDevices(state.gitHost);
|
||
return { ok: true, data: { devices: list } };
|
||
}
|
||
|
||
case 'add_device': {
|
||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
const device = {
|
||
name: msg.name,
|
||
public_key: msg.public_key,
|
||
added_at: Math.floor(Date.now() / 1000),
|
||
};
|
||
await devices.addDevice(state.gitHost, device);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'revoke_device': {
|
||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
await devices.revokeDevice(state.gitHost, msg.name);
|
||
return { ok: true };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create devices.test.ts**
|
||
|
||
Create `extension/src/service-worker/__tests__/devices.test.ts`:
|
||
|
||
```typescript
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { readDevices, addDevice, revokeDevice } from '../devices';
|
||
import type { GitHost } from '../git-host';
|
||
|
||
function makeGitHost(devicesJson = '{"devices":[]}'): GitHost {
|
||
let stored = devicesJson;
|
||
return {
|
||
readFile: vi.fn().mockImplementation(async () => new TextEncoder().encode(stored)),
|
||
writeFile: vi.fn().mockImplementation(async (_p, bytes) => { stored = new TextDecoder().decode(bytes); }),
|
||
deleteFile: vi.fn(),
|
||
listDir: vi.fn(),
|
||
putBlob: vi.fn(),
|
||
getBlob: vi.fn(),
|
||
deleteBlob: vi.fn(),
|
||
};
|
||
}
|
||
|
||
describe('devices', () => {
|
||
it('readDevices returns empty array when file missing', async () => {
|
||
const host = makeGitHost();
|
||
(host.readFile as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('404'));
|
||
const result = await readDevices(host);
|
||
expect(result).toEqual([]);
|
||
});
|
||
|
||
it('readDevices parses existing devices', async () => {
|
||
const host = makeGitHost('{"devices":[{"name":"CLI","public_key":"abc123","added_at":1000}]}');
|
||
const result = await readDevices(host);
|
||
expect(result).toHaveLength(1);
|
||
expect(result[0].name).toBe('CLI');
|
||
});
|
||
|
||
it('addDevice appends to list', async () => {
|
||
const host = makeGitHost();
|
||
await addDevice(host, { name: 'Chrome', public_key: 'def456', added_at: 2000 });
|
||
expect(host.writeFile).toHaveBeenCalled();
|
||
const written = (host.writeFile as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||
const parsed = JSON.parse(new TextDecoder().decode(written));
|
||
expect(parsed.devices).toHaveLength(1);
|
||
expect(parsed.devices[0].name).toBe('Chrome');
|
||
});
|
||
|
||
it('addDevice rejects duplicate name', async () => {
|
||
const host = makeGitHost('{"devices":[{"name":"Chrome","public_key":"abc","added_at":1000}]}');
|
||
await expect(addDevice(host, { name: 'Chrome', public_key: 'xyz', added_at: 2000 }))
|
||
.rejects.toThrow(/already exists/);
|
||
});
|
||
|
||
it('revokeDevice removes by name', async () => {
|
||
const host = makeGitHost('{"devices":[{"name":"CLI","public_key":"a","added_at":1},{"name":"Chrome","public_key":"b","added_at":2}]}');
|
||
await revokeDevice(host, 'CLI');
|
||
const written = (host.writeFile as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||
const parsed = JSON.parse(new TextDecoder().decode(written));
|
||
expect(parsed.devices).toHaveLength(1);
|
||
expect(parsed.devices[0].name).toBe('Chrome');
|
||
});
|
||
|
||
it('revokeDevice throws if not found', async () => {
|
||
const host = makeGitHost();
|
||
await expect(revokeDevice(host, 'nonexistent')).rejects.toThrow(/not found/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run device tests**
|
||
|
||
Run: `cd extension && bun run test src/service-worker/__tests__/devices.test.ts 2>&1 | tail -5`
|
||
|
||
Expected: 6 tests pass.
|
||
|
||
- [ ] **Step 5: Verify type-check**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit 2>&1 | tail -3`
|
||
|
||
Expected: Zero errors.
|
||
|
||
- [ ] **Step 6: Run all tests**
|
||
|
||
Run: `cd extension && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/service-worker/devices.ts \
|
||
extension/src/service-worker/__tests__/devices.test.ts \
|
||
extension/src/service-worker/router/popup-only.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/sw): device management — devices.ts + router handlers
|
||
|
||
Adds readDevices, addDevice, revokeDevice helpers that read/write
|
||
.relicario/devices.json. Router handlers: list_devices, add_device,
|
||
revoke_device.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Service worker — trash helpers + handlers
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/service-worker/vault.ts`
|
||
- Modify: `extension/src/service-worker/router/popup-only.ts`
|
||
- Create: `extension/src/service-worker/__tests__/trash.test.ts`
|
||
|
||
- [ ] **Step 1: Add trash helpers to vault.ts**
|
||
|
||
Edit `extension/src/service-worker/vault.ts` — add these functions after the existing exports:
|
||
|
||
```typescript
|
||
// --- Trash operations ---
|
||
|
||
export function listTrashed(manifest: Manifest): Array<[ItemId, ManifestEntry]> {
|
||
return Object.entries(manifest.items)
|
||
.filter(([, entry]) => entry.trashed_at != null)
|
||
.sort(([, a], [, b]) => (b.trashed_at ?? 0) - (a.trashed_at ?? 0));
|
||
}
|
||
|
||
export async function restoreItem(
|
||
git: GitHost,
|
||
handle: SessionHandle,
|
||
manifest: Manifest,
|
||
itemId: ItemId,
|
||
): Promise<void> {
|
||
const item = await fetchAndDecryptItem(git, handle, itemId);
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const restored: Item = { ...item, trashed_at: undefined, modified: now };
|
||
await encryptAndWriteItem(git, handle, itemId, restored, `restore: ${item.title}`);
|
||
manifest.items[itemId] = { ...manifest.items[itemId], trashed_at: undefined, modified: now };
|
||
await encryptAndWriteManifest(git, handle, manifest, `manifest: restore ${item.title}`);
|
||
}
|
||
|
||
export async function purgeItem(
|
||
git: GitHost,
|
||
itemId: ItemId,
|
||
manifest: Manifest,
|
||
): Promise<string[]> {
|
||
const entry = manifest.items[itemId];
|
||
const deletedBlobs: string[] = [];
|
||
|
||
// Delete attachments
|
||
for (const att of entry?.attachment_summaries ?? []) {
|
||
try {
|
||
await git.deleteBlob(`attachments/${att.id}.bin`, `purge attachment: ${att.filename}`);
|
||
deletedBlobs.push(att.id);
|
||
} catch { /* blob may not exist */ }
|
||
}
|
||
|
||
// Delete item file
|
||
await git.deleteFile(`items/${itemId}.enc`, `purge: ${entry?.title ?? itemId}`);
|
||
|
||
// Update manifest
|
||
delete manifest.items[itemId];
|
||
|
||
return deletedBlobs;
|
||
}
|
||
|
||
export async function purgeAllTrash(
|
||
git: GitHost,
|
||
handle: SessionHandle,
|
||
manifest: Manifest,
|
||
): Promise<{ itemCount: number; orphanCount: number }> {
|
||
const trashed = listTrashed(manifest);
|
||
const allDeletedBlobs = new Set<string>();
|
||
|
||
// Purge each trashed item
|
||
for (const [id] of trashed) {
|
||
const deleted = await purgeItem(git, id, manifest);
|
||
deleted.forEach((b) => allDeletedBlobs.add(b));
|
||
}
|
||
|
||
// Collect all referenced attachment IDs from remaining items
|
||
const referenced = new Set<string>();
|
||
for (const entry of Object.values(manifest.items)) {
|
||
for (const att of entry.attachment_summaries ?? []) {
|
||
referenced.add(att.id);
|
||
}
|
||
}
|
||
|
||
// Scan for orphan blobs
|
||
let orphanCount = 0;
|
||
try {
|
||
const blobFiles = await git.listDir('attachments');
|
||
for (const filename of blobFiles) {
|
||
const id = filename.replace(/\.bin$/, '');
|
||
if (!referenced.has(id) && !allDeletedBlobs.has(id)) {
|
||
try {
|
||
await git.deleteBlob(`attachments/${filename}`, `purge orphan: ${id}`);
|
||
orphanCount++;
|
||
} catch { /* ignore */ }
|
||
}
|
||
}
|
||
} catch { /* attachments dir may not exist */ }
|
||
|
||
// Write manifest once
|
||
if (trashed.length > 0 || orphanCount > 0) {
|
||
await encryptAndWriteManifest(
|
||
git, handle, manifest,
|
||
`trash: purge ${trashed.length} items + ${orphanCount} orphan blobs`,
|
||
);
|
||
}
|
||
|
||
return { itemCount: trashed.length, orphanCount };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update vault.ts imports**
|
||
|
||
Edit `extension/src/service-worker/vault.ts` — ensure `Item` is imported:
|
||
|
||
```typescript
|
||
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
|
||
```
|
||
|
||
- [ ] **Step 3: Add trash handlers to popup-only.ts**
|
||
|
||
Edit `extension/src/service-worker/router/popup-only.ts` — add these cases:
|
||
|
||
```typescript
|
||
case 'list_trashed': {
|
||
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
||
const items = vault.listTrashed(state.manifest);
|
||
return { ok: true, data: { items } };
|
||
}
|
||
|
||
case 'restore_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||
await vault.restoreItem(state.gitHost, handle, state.manifest, msg.id);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'purge_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||
await vault.purgeItem(state.gitHost, msg.id, state.manifest);
|
||
await vault.encryptAndWriteManifest(
|
||
state.gitHost, handle, state.manifest,
|
||
`manifest: purge ${state.manifest.items[msg.id]?.title ?? msg.id}`,
|
||
);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'purge_all_trash': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||
const result = await vault.purgeAllTrash(state.gitHost, handle, state.manifest);
|
||
return { ok: true, data: result };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create trash.test.ts**
|
||
|
||
Create `extension/src/service-worker/__tests__/trash.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, expect, it } from 'vitest';
|
||
import { listTrashed } from '../vault';
|
||
import type { Manifest } from '../../shared/types';
|
||
|
||
function makeManifest(items: Record<string, { trashed_at?: number }>): Manifest {
|
||
const manifest: Manifest = { schema_version: 2, items: {} };
|
||
for (const [id, { trashed_at }] of Object.entries(items)) {
|
||
manifest.items[id] = {
|
||
id,
|
||
type: 'login',
|
||
title: `Item ${id}`,
|
||
tags: [],
|
||
favorite: false,
|
||
modified: 1000,
|
||
trashed_at,
|
||
attachment_summaries: [],
|
||
};
|
||
}
|
||
return manifest;
|
||
}
|
||
|
||
describe('listTrashed', () => {
|
||
it('returns empty array when no trashed items', () => {
|
||
const manifest = makeManifest({ a: {}, b: {} });
|
||
expect(listTrashed(manifest)).toEqual([]);
|
||
});
|
||
|
||
it('filters to only trashed items', () => {
|
||
const manifest = makeManifest({
|
||
a: {},
|
||
b: { trashed_at: 1000 },
|
||
c: { trashed_at: 2000 },
|
||
});
|
||
const result = listTrashed(manifest);
|
||
expect(result).toHaveLength(2);
|
||
expect(result.map(([id]) => id)).toEqual(['c', 'b']); // sorted newest first
|
||
});
|
||
|
||
it('sorts by trashed_at descending', () => {
|
||
const manifest = makeManifest({
|
||
old: { trashed_at: 100 },
|
||
mid: { trashed_at: 500 },
|
||
new: { trashed_at: 900 },
|
||
});
|
||
const result = listTrashed(manifest);
|
||
expect(result.map(([id]) => id)).toEqual(['new', 'mid', 'old']);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run trash tests**
|
||
|
||
Run: `cd extension && bun run test src/service-worker/__tests__/trash.test.ts 2>&1 | tail -5`
|
||
|
||
Expected: 3 tests pass.
|
||
|
||
- [ ] **Step 6: Verify type-check + run all tests**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: Zero type errors, all tests pass.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/service-worker/vault.ts \
|
||
extension/src/service-worker/__tests__/trash.test.ts \
|
||
extension/src/service-worker/router/popup-only.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/sw): trash operations — listTrashed, restoreItem, purgeItem, purgeAllTrash
|
||
|
||
listTrashed filters manifest for trashed_at != null, sorted newest-first.
|
||
restoreItem clears trashed_at. purgeItem deletes item + attachments.
|
||
purgeAllTrash also scans for orphan blobs in attachments/ directory.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Service worker — field history handler
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/service-worker/router/popup-only.ts`
|
||
|
||
- [ ] **Step 1: Add get_field_history handler**
|
||
|
||
Edit `extension/src/service-worker/router/popup-only.ts` — add this case:
|
||
|
||
```typescript
|
||
case 'get_field_history': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||
const history = state.wasm.get_field_history(JSON.stringify(item));
|
||
return { ok: true, data: { history } };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify type-check + run tests**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: Zero type errors, all tests pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/service-worker/router/popup-only.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/sw): get_field_history handler
|
||
|
||
Decrypts item and calls WASM get_field_history to extract tracked
|
||
field history for the popup's history view.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Popup — trash screen
|
||
|
||
**Files:**
|
||
- Create: `extension/src/popup/components/trash.ts`
|
||
- Modify: `extension/src/popup/styles.css`
|
||
- Create: `extension/src/popup/components/__tests__/trash.test.ts`
|
||
|
||
- [ ] **Step 1: Create trash.ts**
|
||
|
||
Create `extension/src/popup/components/trash.ts`:
|
||
|
||
```typescript
|
||
/// Trash view — lists soft-deleted items with restore/purge actions.
|
||
|
||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
|
||
|
||
const TYPE_ICONS: Record<string, string> = {
|
||
login: '🔑', secure_note: '📝', identity: '👤', card: '💳',
|
||
key: '🔐', document: '📄', totp: '⏱️',
|
||
};
|
||
|
||
function relativeTime(unixSec: number): string {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const diff = now - unixSec;
|
||
if (diff < 60) return 'just now';
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||
return `${Math.floor(diff / 86400)}d ago`;
|
||
}
|
||
|
||
function daysUntilPurge(trashedAt: number, retention: VaultSettings['trash_retention']): number | null {
|
||
if (retention.kind === 'forever') return null;
|
||
const trashedDaysAgo = Math.floor((Date.now() / 1000 - trashedAt) / 86400);
|
||
return Math.max(0, retention.value - trashedDaysAgo);
|
||
}
|
||
|
||
export function teardown(): void {
|
||
// No cleanup needed
|
||
}
|
||
|
||
export async function renderTrash(app: HTMLElement): Promise<void> {
|
||
const state = getState();
|
||
|
||
// Fetch trashed items
|
||
const resp = await sendMessage({ type: 'list_trashed' });
|
||
if (!resp.ok) {
|
||
app.innerHTML = `<div class="pad"><p class="error">Failed to load trash</p></div>`;
|
||
return;
|
||
}
|
||
|
||
const items = (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items;
|
||
const retention = state.vaultSettings?.trash_retention ?? { kind: 'days', value: 30 };
|
||
|
||
// Calculate days until oldest auto-purges
|
||
let oldestPurgeDays: number | null = null;
|
||
if (items.length > 0 && retention.kind === 'days') {
|
||
const oldest = items[items.length - 1][1];
|
||
oldestPurgeDays = daysUntilPurge(oldest.trashed_at ?? 0, retention);
|
||
}
|
||
|
||
const headerInfo = items.length === 0
|
||
? ''
|
||
: oldestPurgeDays !== null
|
||
? `${items.length} item${items.length === 1 ? '' : 's'} · oldest auto-purges in ${oldestPurgeDays}d`
|
||
: `${items.length} item${items.length === 1 ? '' : 's'}`;
|
||
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="trash-header">
|
||
<button class="btn" id="back-btn">← back</button>
|
||
<h3 style="margin:0;">trash</h3>
|
||
</div>
|
||
${headerInfo ? `<p class="muted" style="margin:8px 0;">${escapeHtml(headerInfo)}</p>` : ''}
|
||
${items.length === 0
|
||
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
|
||
: items.map(([id, entry]) => `
|
||
<div class="trash-row" data-id="${escapeHtml(id)}">
|
||
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '📦'}</span>
|
||
<div class="trash-row__info">
|
||
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
||
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
|
||
</div>
|
||
<button class="trash-row__restore" data-restore="${escapeHtml(id)}">restore</button>
|
||
</div>
|
||
`).join('')}
|
||
${items.length > 0 ? `
|
||
<div style="margin-top:16px;text-align:center;">
|
||
<button class="btn danger" id="empty-trash-btn">empty trash</button>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
|
||
// Wire handlers
|
||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||
|
||
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((btn) => {
|
||
btn.addEventListener('click', async () => {
|
||
const id = btn.dataset.restore;
|
||
if (!id) return;
|
||
btn.disabled = true;
|
||
btn.textContent = '...';
|
||
const result = await sendMessage({ type: 'restore_item', id });
|
||
if (result.ok) {
|
||
await sendMessage({ type: 'sync' });
|
||
renderTrash(app);
|
||
} else {
|
||
setState({ error: result.error });
|
||
}
|
||
});
|
||
});
|
||
|
||
document.getElementById('empty-trash-btn')?.addEventListener('click', async () => {
|
||
if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) {
|
||
return;
|
||
}
|
||
const btn = document.getElementById('empty-trash-btn') as HTMLButtonElement;
|
||
btn.disabled = true;
|
||
btn.textContent = 'deleting...';
|
||
const result = await sendMessage({ type: 'purge_all_trash' });
|
||
if (result.ok) {
|
||
await sendMessage({ type: 'sync' });
|
||
renderTrash(app);
|
||
} else {
|
||
setState({ error: result.error });
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add trash styles to styles.css**
|
||
|
||
Edit `extension/src/popup/styles.css` — add at the end:
|
||
|
||
```css
|
||
/* --- Trash view --- */
|
||
|
||
.trash-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.trash-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
background: #161b22;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.trash-row__icon {
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.trash-row__info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.trash-row__title {
|
||
display: block;
|
||
font-size: 13px;
|
||
color: #c9d1d9;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.trash-row__meta {
|
||
font-size: 11px;
|
||
color: #8b949e;
|
||
}
|
||
|
||
.trash-row__restore {
|
||
font-size: 11px;
|
||
padding: 4px 8px;
|
||
background: #238636;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.trash-row__restore:hover {
|
||
background: #2ea043;
|
||
}
|
||
|
||
.trash-row__restore:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create trash.test.ts**
|
||
|
||
Create `extension/src/popup/components/__tests__/trash.test.ts`:
|
||
|
||
```typescript
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { renderTrash } from '../trash';
|
||
|
||
// Mock popup module
|
||
vi.mock('../../popup', () => ({
|
||
getState: vi.fn(() => ({
|
||
vaultSettings: { trash_retention: { kind: 'days', value: 30 } },
|
||
})),
|
||
setState: vi.fn(),
|
||
sendMessage: vi.fn(),
|
||
navigate: vi.fn(),
|
||
escapeHtml: (s: string) => s,
|
||
}));
|
||
|
||
import { sendMessage, navigate } from '../../popup';
|
||
|
||
describe('trash view', () => {
|
||
let app: HTMLElement;
|
||
|
||
beforeEach(() => {
|
||
app = document.createElement('div');
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('renders empty state when no trashed items', async () => {
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: { items: [] },
|
||
});
|
||
|
||
await renderTrash(app);
|
||
|
||
expect(app.innerHTML).toContain('Trash is empty');
|
||
expect(app.querySelector('#empty-trash-btn')).toBeNull();
|
||
});
|
||
|
||
it('renders trashed items with restore buttons', async () => {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: {
|
||
items: [
|
||
['id1', { id: 'id1', type: 'login', title: 'Test Login', trashed_at: now - 3600, tags: [], favorite: false, modified: now, attachment_summaries: [] }],
|
||
],
|
||
},
|
||
});
|
||
|
||
await renderTrash(app);
|
||
|
||
expect(app.innerHTML).toContain('Test Login');
|
||
expect(app.innerHTML).toContain('restore');
|
||
expect(app.querySelector('#empty-trash-btn')).not.toBeNull();
|
||
});
|
||
|
||
it('back button navigates to list', async () => {
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: { items: [] },
|
||
});
|
||
|
||
await renderTrash(app);
|
||
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
|
||
|
||
expect(navigate).toHaveBeenCalledWith('list');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run trash tests**
|
||
|
||
Run: `cd extension && bun run test src/popup/components/__tests__/trash.test.ts 2>&1 | tail -5`
|
||
|
||
Expected: 3 tests pass.
|
||
|
||
- [ ] **Step 5: Verify type-check + run all tests**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: Zero type errors, all tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/trash.ts \
|
||
extension/src/popup/components/__tests__/trash.test.ts \
|
||
extension/src/popup/styles.css
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): trash view — list trashed items with restore/purge
|
||
|
||
Shows trashed items sorted newest-first with restore buttons.
|
||
Empty trash button purges all items + orphan blobs. Header shows
|
||
count and days until oldest auto-purges.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Popup — devices screen
|
||
|
||
**Files:**
|
||
- Create: `extension/src/popup/components/devices.ts`
|
||
- Modify: `extension/src/popup/styles.css`
|
||
- Create: `extension/src/popup/components/__tests__/devices.test.ts`
|
||
|
||
- [ ] **Step 1: Create devices.ts**
|
||
|
||
Create `extension/src/popup/components/devices.ts`:
|
||
|
||
```typescript
|
||
/// Device management view — list devices with revoke actions.
|
||
|
||
import { setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||
import type { Device } from '../../shared/types';
|
||
|
||
function relativeTime(unixSec: number): string {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const diff = now - unixSec;
|
||
if (diff < 60) return 'just now';
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
|
||
return `${Math.floor(diff / 2592000)}mo ago`;
|
||
}
|
||
|
||
export function teardown(): void {
|
||
// No cleanup needed
|
||
}
|
||
|
||
export async function renderDevices(app: HTMLElement): Promise<void> {
|
||
// Get current device name from local storage
|
||
const stored = await chrome.storage.local.get(['device_name']);
|
||
const currentDeviceName: string | undefined = stored.device_name;
|
||
|
||
// Fetch device list
|
||
const resp = await sendMessage({ type: 'list_devices' });
|
||
if (!resp.ok) {
|
||
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
||
return;
|
||
}
|
||
|
||
const devices = (resp.data as { devices: Device[] }).devices;
|
||
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
|
||
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="devices-header">
|
||
<button class="btn" id="back-btn">← back</button>
|
||
<h3 style="margin:0;">devices</h3>
|
||
</div>
|
||
${!isRegistered ? `
|
||
<div class="device-banner">
|
||
<span>⚠ This device is not registered</span>
|
||
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
||
</div>
|
||
` : ''}
|
||
${devices.length === 0
|
||
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
||
: devices.map((d) => {
|
||
const isCurrentDevice = d.name === currentDeviceName;
|
||
return `
|
||
<div class="device-row">
|
||
<div class="device-row__info">
|
||
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
|
||
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
|
||
</div>
|
||
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// Wire handlers
|
||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||
|
||
document.getElementById('register-btn')?.addEventListener('click', async () => {
|
||
// Generate keypair and register
|
||
// This would need WASM access - for now, redirect to a registration flow
|
||
// The full implementation happens in Task 12 (setup wizard integration)
|
||
setState({ error: 'Device registration from here is not yet implemented. Use setup wizard.' });
|
||
});
|
||
|
||
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
|
||
btn.addEventListener('click', async () => {
|
||
const name = btn.dataset.revoke;
|
||
if (!name) return;
|
||
if (!confirm(`Revoke ${name}? This device will no longer be authorized.`)) return;
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = '...';
|
||
const result = await sendMessage({ type: 'revoke_device', name });
|
||
if (result.ok) {
|
||
renderDevices(app);
|
||
} else {
|
||
setState({ error: result.error });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add device styles to styles.css**
|
||
|
||
Edit `extension/src/popup/styles.css` — add at the end:
|
||
|
||
```css
|
||
/* --- Devices view --- */
|
||
|
||
.devices-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.device-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 10px;
|
||
background: #3d1f00;
|
||
border: 1px solid #9e6a03;
|
||
border-radius: 4px;
|
||
margin-bottom: 12px;
|
||
font-size: 12px;
|
||
color: #f0c674;
|
||
}
|
||
|
||
.device-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px;
|
||
border-radius: 4px;
|
||
background: #161b22;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.device-row__info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.device-row__name {
|
||
display: block;
|
||
font-size: 13px;
|
||
color: #c9d1d9;
|
||
}
|
||
|
||
.device-row__you {
|
||
font-size: 11px;
|
||
color: #58a6ff;
|
||
}
|
||
|
||
.device-row__meta {
|
||
font-size: 11px;
|
||
color: #8b949e;
|
||
}
|
||
|
||
.device-row__revoke {
|
||
font-size: 11px;
|
||
padding: 4px 8px;
|
||
background: #da3633;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.device-row__revoke:hover {
|
||
background: #f85149;
|
||
}
|
||
|
||
.device-row__revoke:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create devices.test.ts**
|
||
|
||
Create `extension/src/popup/components/__tests__/devices.test.ts`:
|
||
|
||
```typescript
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { renderDevices } from '../devices';
|
||
|
||
// Mock chrome.storage.local
|
||
// @ts-expect-error test harness
|
||
globalThis.chrome = {
|
||
storage: {
|
||
local: {
|
||
get: vi.fn().mockResolvedValue({ device_name: 'Chrome on Linux' }),
|
||
},
|
||
},
|
||
};
|
||
|
||
// Mock popup module
|
||
vi.mock('../../popup', () => ({
|
||
setState: vi.fn(),
|
||
sendMessage: vi.fn(),
|
||
navigate: vi.fn(),
|
||
escapeHtml: (s: string) => s,
|
||
}));
|
||
|
||
import { sendMessage, navigate } from '../../popup';
|
||
|
||
describe('devices view', () => {
|
||
let app: HTMLElement;
|
||
|
||
beforeEach(() => {
|
||
app = document.createElement('div');
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('renders empty state when no devices', async () => {
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: { devices: [] },
|
||
});
|
||
|
||
await renderDevices(app);
|
||
|
||
expect(app.innerHTML).toContain('No devices registered');
|
||
});
|
||
|
||
it('renders devices with "you" indicator on current device', async () => {
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: {
|
||
devices: [
|
||
{ name: 'Chrome on Linux', public_key: 'abc', added_at: 1000 },
|
||
{ name: 'CLI', public_key: 'def', added_at: 500 },
|
||
],
|
||
},
|
||
});
|
||
|
||
await renderDevices(app);
|
||
|
||
expect(app.innerHTML).toContain('Chrome on Linux');
|
||
expect(app.innerHTML).toContain('← you');
|
||
expect(app.innerHTML).toContain('CLI');
|
||
// Current device should not have revoke button
|
||
const rows = app.querySelectorAll('.device-row');
|
||
expect(rows[0].querySelector('[data-revoke]')).toBeNull();
|
||
expect(rows[1].querySelector('[data-revoke]')).not.toBeNull();
|
||
});
|
||
|
||
it('shows unregistered banner when current device not in list', async () => {
|
||
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: {
|
||
devices: [{ name: 'CLI', public_key: 'abc', added_at: 1000 }],
|
||
},
|
||
});
|
||
|
||
await renderDevices(app);
|
||
|
||
expect(app.innerHTML).toContain('This device is not registered');
|
||
});
|
||
|
||
it('back button navigates to list', async () => {
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: { devices: [] },
|
||
});
|
||
|
||
await renderDevices(app);
|
||
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
|
||
|
||
expect(navigate).toHaveBeenCalledWith('list');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run devices tests**
|
||
|
||
Run: `cd extension && bun run test src/popup/components/__tests__/devices.test.ts 2>&1 | tail -5`
|
||
|
||
Expected: 4 tests pass.
|
||
|
||
- [ ] **Step 5: Verify type-check + run all tests**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: Zero type errors, all tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/devices.ts \
|
||
extension/src/popup/components/__tests__/devices.test.ts \
|
||
extension/src/popup/styles.css
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): devices view — list devices with revoke actions
|
||
|
||
Shows registered devices with "← you" indicator on current device.
|
||
Revoke button on other devices. Unregistered banner if current
|
||
device not in list.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Popup — field history screen
|
||
|
||
**Files:**
|
||
- Create: `extension/src/popup/components/field-history.ts`
|
||
- Modify: `extension/src/popup/styles.css`
|
||
- Create: `extension/src/popup/components/__tests__/field-history.test.ts`
|
||
|
||
- [ ] **Step 1: Create field-history.ts**
|
||
|
||
Create `extension/src/popup/components/field-history.ts`:
|
||
|
||
```typescript
|
||
/// Field history view — shows password/concealed field history for an item.
|
||
|
||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||
import type { FieldHistoryView } from '../../shared/types';
|
||
|
||
function relativeTime(unixSec: number): string {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const diff = now - unixSec;
|
||
if (diff < 60) return 'just now';
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`;
|
||
return `${Math.floor(diff / 2592000)}mo ago`;
|
||
}
|
||
|
||
const revealedSet = new Set<string>();
|
||
|
||
export function teardown(): void {
|
||
revealedSet.clear();
|
||
}
|
||
|
||
export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
||
const state = getState();
|
||
const itemId = state.historyItemId;
|
||
const item = state.selectedItem;
|
||
|
||
if (!itemId || !item) {
|
||
navigate('list');
|
||
return;
|
||
}
|
||
|
||
// Fetch field history
|
||
const resp = await sendMessage({ type: 'get_field_history', id: itemId });
|
||
if (!resp.ok) {
|
||
app.innerHTML = `<div class="pad"><p class="error">Failed to load history</p></div>`;
|
||
return;
|
||
}
|
||
|
||
const history = (resp.data as { history: FieldHistoryView[] }).history;
|
||
|
||
if (history.length === 0) {
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="history-header">
|
||
<button class="btn" id="back-btn">← back to item</button>
|
||
<h3 style="margin:0;">password history</h3>
|
||
</div>
|
||
<p class="muted" style="text-align:center;margin-top:32px;">No history available</p>
|
||
</div>
|
||
`;
|
||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('detail'));
|
||
return;
|
||
}
|
||
|
||
function renderEntry(fieldId: string, value: string, timestamp: number, isCurrent: boolean): string {
|
||
const entryKey = `${fieldId}-${timestamp}`;
|
||
const isRevealed = revealedSet.has(entryKey);
|
||
const displayValue = isRevealed ? escapeHtml(value) : '••••••••••••';
|
||
|
||
return `
|
||
<div class="history-entry" data-entry="${escapeHtml(entryKey)}">
|
||
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
|
||
<div class="history-entry__meta">
|
||
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
|
||
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
|
||
</div>
|
||
<button class="history-entry__copy" data-copy="${escapeHtml(value)}" title="Copy">📋</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
let content = '';
|
||
for (const field of history) {
|
||
if (history.length > 1) {
|
||
content += `<div class="history-field-label">${escapeHtml(field.field_name)}</div>`;
|
||
}
|
||
// Current value first
|
||
content += renderEntry(field.field_id, field.current_value, item.modified, true);
|
||
// Historical values
|
||
for (const entry of field.entries) {
|
||
content += renderEntry(field.field_id, entry.value, entry.changed_at, false);
|
||
}
|
||
}
|
||
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="history-header">
|
||
<button class="btn" id="back-btn">← back to item</button>
|
||
<h3 style="margin:0;">password history</h3>
|
||
</div>
|
||
<div class="history-item-title">${escapeHtml(item.title)}</div>
|
||
${content}
|
||
</div>
|
||
`;
|
||
|
||
// Wire handlers
|
||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('detail'));
|
||
|
||
// Toggle reveal on click
|
||
document.querySelectorAll<HTMLElement>('.history-entry').forEach((el) => {
|
||
el.addEventListener('click', (e) => {
|
||
if ((e.target as HTMLElement).classList.contains('history-entry__copy')) return;
|
||
const key = el.dataset.entry;
|
||
if (!key) return;
|
||
if (revealedSet.has(key)) {
|
||
revealedSet.delete(key);
|
||
} else {
|
||
revealedSet.add(key);
|
||
}
|
||
renderFieldHistory(app);
|
||
});
|
||
});
|
||
|
||
// Copy buttons
|
||
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const value = btn.dataset.copy ?? '';
|
||
await navigator.clipboard.writeText(value);
|
||
btn.textContent = '✓';
|
||
setTimeout(() => { btn.textContent = '📋'; }, 1500);
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add history styles to styles.css**
|
||
|
||
Edit `extension/src/popup/styles.css` — add at the end:
|
||
|
||
```css
|
||
/* --- Field history view --- */
|
||
|
||
.history-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.history-item-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #c9d1d9;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.history-field-label {
|
||
font-size: 11px;
|
||
color: #8b949e;
|
||
text-transform: uppercase;
|
||
margin: 12px 0 6px;
|
||
}
|
||
|
||
.history-entry {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px;
|
||
border-radius: 4px;
|
||
background: #161b22;
|
||
margin-bottom: 6px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.history-entry:hover {
|
||
background: #1c2128;
|
||
}
|
||
|
||
.history-entry__value {
|
||
flex: 1;
|
||
font-family: monospace;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.history-entry__value.masked {
|
||
color: #8b949e;
|
||
}
|
||
|
||
.history-entry__value.revealed {
|
||
color: #c9d1d9;
|
||
}
|
||
|
||
.history-entry__meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 2px;
|
||
font-size: 11px;
|
||
color: #8b949e;
|
||
}
|
||
|
||
.history-entry__current {
|
||
color: #58a6ff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.history-entry__copy {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
padding: 4px;
|
||
}
|
||
|
||
.history-entry__copy:hover {
|
||
opacity: 0.8;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create field-history.test.ts**
|
||
|
||
Create `extension/src/popup/components/__tests__/field-history.test.ts`:
|
||
|
||
```typescript
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { renderFieldHistory, teardown } from '../field-history';
|
||
|
||
// Mock popup module
|
||
vi.mock('../../popup', () => ({
|
||
getState: vi.fn(() => ({
|
||
historyItemId: 'item123',
|
||
selectedItem: { id: 'item123', title: 'Test Item', modified: 1000 },
|
||
})),
|
||
setState: vi.fn(),
|
||
sendMessage: vi.fn(),
|
||
navigate: vi.fn(),
|
||
escapeHtml: (s: string) => s,
|
||
}));
|
||
|
||
import { sendMessage, navigate } from '../../popup';
|
||
|
||
describe('field-history view', () => {
|
||
let app: HTMLElement;
|
||
|
||
beforeEach(() => {
|
||
app = document.createElement('div');
|
||
teardown();
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('renders empty state when no history', async () => {
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: { history: [] },
|
||
});
|
||
|
||
await renderFieldHistory(app);
|
||
|
||
expect(app.innerHTML).toContain('No history available');
|
||
});
|
||
|
||
it('renders history entries masked by default', async () => {
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: {
|
||
history: [{
|
||
field_id: 'f1',
|
||
field_name: 'password',
|
||
current_value: 'secret123',
|
||
entries: [{ value: 'oldpass', changed_at: 500 }],
|
||
}],
|
||
},
|
||
});
|
||
|
||
await renderFieldHistory(app);
|
||
|
||
expect(app.innerHTML).toContain('••••••••••••');
|
||
expect(app.innerHTML).not.toContain('secret123');
|
||
expect(app.innerHTML).toContain('current');
|
||
});
|
||
|
||
it('back button navigates to detail', async () => {
|
||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
ok: true,
|
||
data: { history: [] },
|
||
});
|
||
|
||
await renderFieldHistory(app);
|
||
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
|
||
|
||
expect(navigate).toHaveBeenCalledWith('detail');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run field-history tests**
|
||
|
||
Run: `cd extension && bun run test src/popup/components/__tests__/field-history.test.ts 2>&1 | tail -5`
|
||
|
||
Expected: 3 tests pass.
|
||
|
||
- [ ] **Step 5: Verify type-check + run all tests**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: Zero type errors, all tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/field-history.ts \
|
||
extension/src/popup/components/__tests__/field-history.test.ts \
|
||
extension/src/popup/styles.css
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): field history view — masked values with reveal toggle
|
||
|
||
Shows current + historical values for tracked fields (password/concealed).
|
||
Click to reveal, copy button per entry. Grouped by field name if multiple
|
||
tracked fields exist.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Popup — "View history" link in item detail
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/components/types/login.ts`
|
||
- Modify: Other type detail files (similar pattern)
|
||
|
||
- [ ] **Step 1: Add "View history" link to login detail**
|
||
|
||
Edit `extension/src/popup/components/types/login.ts` — find the detail view's form-actions div (around line 78-80) and add a history link:
|
||
|
||
Find this block:
|
||
```typescript
|
||
<div class="form-actions" style="margin-top:14px;">
|
||
<button class="btn" id="back-btn">back</button>
|
||
<button class="btn" id="edit-btn">edit</button>
|
||
```
|
||
|
||
Replace with:
|
||
```typescript
|
||
<div class="form-actions" style="margin-top:14px;">
|
||
<button class="btn" id="back-btn">back</button>
|
||
${Object.keys(item.field_history).length > 0 ? '<button class="btn" id="history-btn">view history</button>' : ''}
|
||
<button class="btn" id="edit-btn">edit</button>
|
||
```
|
||
|
||
- [ ] **Step 2: Wire history button handler**
|
||
|
||
In the same file, find where `edit-btn` handler is wired (around line 90-95) and add before it:
|
||
|
||
```typescript
|
||
document.getElementById('history-btn')?.addEventListener('click', () => {
|
||
setState({ historyItemId: item.id });
|
||
navigate('field-history');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Add setState import if not present**
|
||
|
||
Ensure `setState` is imported at the top of `login.ts`:
|
||
|
||
```typescript
|
||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||
```
|
||
|
||
- [ ] **Step 4: Verify type-check + run tests**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: Zero type errors, all tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/types/login.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): add "View history" link to login detail view
|
||
|
||
Shows button when item.field_history is non-empty. Navigates to
|
||
field-history screen with historyItemId set.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Popup — attachment caps in vault settings
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/components/settings-vault.ts`
|
||
|
||
- [ ] **Step 1: Add attachment caps section to settings-vault.ts**
|
||
|
||
Edit `extension/src/popup/components/settings-vault.ts` — find the `autofill origins` section (around line 135-145) and add after it, before the `settings-footer` div:
|
||
|
||
```typescript
|
||
<div class="settings-section">
|
||
<div class="settings-section__title">attachments</div>
|
||
<div class="settings-row">
|
||
<span class="settings-row__label">max file size</span>
|
||
<select id="attachment-cap">
|
||
<option value="5242880">5 MB</option>
|
||
<option value="10485760">10 MB</option>
|
||
<option value="26214400">25 MB</option>
|
||
<option value="52428800">50 MB</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 2: Set initial value for attachment cap**
|
||
|
||
In the `rerender()` function, after setting trash/history retention values, add:
|
||
|
||
```typescript
|
||
const capValue = pendingSettings.attachment_caps?.per_attachment_max_bytes ?? 10485760;
|
||
(document.getElementById('attachment-cap') as HTMLSelectElement).value = String(capValue);
|
||
```
|
||
|
||
- [ ] **Step 3: Wire change handler**
|
||
|
||
In `wireHandlers()`, add:
|
||
|
||
```typescript
|
||
document.getElementById('attachment-cap')?.addEventListener('change', (e) => {
|
||
if (!pendingSettings) return;
|
||
const bytes = Number((e.target as HTMLSelectElement).value);
|
||
pendingSettings.attachment_caps = {
|
||
...pendingSettings.attachment_caps,
|
||
per_attachment_max_bytes: bytes,
|
||
};
|
||
updateSaveEnabled();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Verify type-check + run tests**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: Zero type errors, all tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/settings-vault.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): add attachment cap setting to vault settings
|
||
|
||
Dropdown with 5/10/25/50 MB presets for per_attachment_max_bytes.
|
||
Other caps remain at defaults.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Popup — navigation for trash + devices + field-history
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/popup.ts`
|
||
- Modify: `extension/src/popup/components/item-list.ts` or `settings.ts` (add entry points)
|
||
|
||
- [ ] **Step 1: Update View type in popup.ts**
|
||
|
||
Edit `extension/src/popup/popup.ts` — update the View type (around line 27):
|
||
|
||
```typescript
|
||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
|
||
```
|
||
|
||
- [ ] **Step 2: Add historyItemId to PopupState**
|
||
|
||
In the PopupState interface, add:
|
||
|
||
```typescript
|
||
historyItemId: string | null;
|
||
```
|
||
|
||
And in the initial state object:
|
||
|
||
```typescript
|
||
historyItemId: null,
|
||
```
|
||
|
||
- [ ] **Step 3: Add imports for new screens**
|
||
|
||
Add imports at the top:
|
||
|
||
```typescript
|
||
import { renderTrash } from './components/trash';
|
||
import { renderDevices } from './components/devices';
|
||
import { renderFieldHistory } from './components/field-history';
|
||
```
|
||
|
||
- [ ] **Step 4: Add cases to render switch**
|
||
|
||
In the `render()` function's switch statement, add:
|
||
|
||
```typescript
|
||
case 'trash':
|
||
renderTrash(app);
|
||
break;
|
||
case 'devices':
|
||
renderDevices(app);
|
||
break;
|
||
case 'field-history':
|
||
renderFieldHistory(app);
|
||
break;
|
||
```
|
||
|
||
- [ ] **Step 5: Add teardown calls**
|
||
|
||
Import teardown functions:
|
||
|
||
```typescript
|
||
import { teardown as teardownTrash } from './components/trash';
|
||
import { teardown as teardownDevices } from './components/devices';
|
||
import { teardown as teardownFieldHistory } from './components/field-history';
|
||
```
|
||
|
||
Add teardown calls at the beginning of render() (after the existing teardowns if any):
|
||
|
||
```typescript
|
||
teardownTrash();
|
||
teardownDevices();
|
||
teardownFieldHistory();
|
||
```
|
||
|
||
- [ ] **Step 6: Add navigation links in settings.ts**
|
||
|
||
Edit `extension/src/popup/components/settings.ts` — add trash and devices links to the settings menu:
|
||
|
||
Find the settings menu HTML and add:
|
||
|
||
```typescript
|
||
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">🗑️ Trash</button>
|
||
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">🔐 Devices</button>
|
||
```
|
||
|
||
Wire the handlers:
|
||
|
||
```typescript
|
||
document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash'));
|
||
document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices'));
|
||
```
|
||
|
||
- [ ] **Step 7: Verify type-check + run tests**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`
|
||
|
||
Expected: Zero type errors, all tests pass.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/popup.ts \
|
||
extension/src/popup/components/settings.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): wire navigation for trash, devices, field-history screens
|
||
|
||
Adds View variants, render cases, teardown calls, and entry points
|
||
in settings menu for trash and devices.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Setup wizard — device name step
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/setup/setup.ts`
|
||
|
||
- [ ] **Step 1: Add device name to wizard state**
|
||
|
||
Edit `extension/src/setup/setup.ts` — add to the WizardState interface (around line 29):
|
||
|
||
```typescript
|
||
deviceName: string;
|
||
```
|
||
|
||
And in the initial state object:
|
||
|
||
```typescript
|
||
deviceName: '',
|
||
```
|
||
|
||
- [ ] **Step 2: Update step count — now 5 steps**
|
||
|
||
The wizard currently has 4 steps. Device name becomes step 4, finish becomes step 5.
|
||
|
||
Update `renderStep4` to `renderStep5` and `attachStep4` to `attachStep5`.
|
||
|
||
Update the progress bar to have 5 steps instead of 4.
|
||
|
||
- [ ] **Step 3: Create new step 4 for device name**
|
||
|
||
Add `renderStep4()` function:
|
||
|
||
```typescript
|
||
function renderStep4(): string {
|
||
const platform = navigator.platform.toLowerCase();
|
||
const isChrome = /chrome/i.test(navigator.userAgent) && !/edg/i.test(navigator.userAgent);
|
||
const isFirefox = /firefox/i.test(navigator.userAgent);
|
||
const browser = isFirefox ? 'Firefox' : isChrome ? 'Chrome' : 'Browser';
|
||
const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : 'Linux';
|
||
const defaultName = state.deviceName || `${browser} on ${os}`;
|
||
|
||
return `
|
||
<div class="wizard-step">
|
||
<h3>name this device</h3>
|
||
<p class="muted" style="margin-bottom:12px;">
|
||
This helps you identify which devices have access to your vault.
|
||
</p>
|
||
<div class="form-group">
|
||
<label class="label" for="device-name">device name</label>
|
||
<input id="device-name" type="text" value="${escapeHtml(defaultName)}" placeholder="e.g. Chrome on Linux">
|
||
</div>
|
||
<div class="form-actions">
|
||
<button class="btn" id="back-btn">back</button>
|
||
<button class="btn btn-primary" id="next-btn">continue</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function attachStep4(): void {
|
||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||
state.step = 3;
|
||
state.error = null;
|
||
render();
|
||
});
|
||
|
||
document.getElementById('next-btn')?.addEventListener('click', async () => {
|
||
const nameInput = document.getElementById('device-name') as HTMLInputElement;
|
||
const name = nameInput.value.trim();
|
||
if (!name) {
|
||
state.error = 'Device name is required';
|
||
render();
|
||
return;
|
||
}
|
||
|
||
state.deviceName = name;
|
||
state.step = 5;
|
||
state.error = null;
|
||
render();
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update step 3 to proceed to step 4 instead of step 5**
|
||
|
||
In the create vault success handler (around line 650), change:
|
||
|
||
```typescript
|
||
state.step = 4;
|
||
```
|
||
|
||
to:
|
||
|
||
```typescript
|
||
state.step = 4; // device name step
|
||
```
|
||
|
||
- [ ] **Step 5: Generate and store device keypair in step 5**
|
||
|
||
In `attachStep5` (formerly attachStep4), when pushing config to extension, also generate and register the device:
|
||
|
||
After the `save_setup` message succeeds, add:
|
||
|
||
```typescript
|
||
// Generate device keypair and register
|
||
const w = await loadWasm();
|
||
const keypair = JSON.parse(w.generate_device_keypair());
|
||
|
||
// Store private key locally
|
||
await chrome.storage.local.set({
|
||
device_name: state.deviceName,
|
||
device_private_key: keypair.private_key_base64,
|
||
});
|
||
|
||
// Register device with vault
|
||
chrome.runtime.sendMessage({
|
||
type: 'add_device',
|
||
name: state.deviceName,
|
||
public_key: keypair.public_key_hex,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Update progress bar in render()**
|
||
|
||
Update the progress bar HTML to have 5 steps:
|
||
|
||
```typescript
|
||
const progressHtml = `
|
||
<div class="progress-bar">
|
||
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
|
||
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
|
||
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
|
||
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
|
||
<div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
|
||
</div>
|
||
`;
|
||
```
|
||
|
||
And update the switch statement:
|
||
|
||
```typescript
|
||
switch (state.step) {
|
||
case 1: stepHtml = renderStep1(); break;
|
||
case 2: stepHtml = renderStep2(); break;
|
||
case 3: stepHtml = renderStep3(); break;
|
||
case 4: stepHtml = renderStep4(); break;
|
||
case 5: stepHtml = renderStep5(); break;
|
||
}
|
||
```
|
||
|
||
And attachments:
|
||
|
||
```typescript
|
||
switch (state.step) {
|
||
case 1: attachStep1(); break;
|
||
case 2: attachStep2(); break;
|
||
case 3: attachStep3(); break;
|
||
case 4: attachStep4(); break;
|
||
case 5: attachStep5(); break;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Verify type-check + build**
|
||
|
||
Run: `cd extension && bunx tsc --noEmit && bun run build 2>&1 | tail -5`
|
||
|
||
Expected: Zero type errors, build succeeds.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/setup/setup.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/setup): add device name step to setup wizard
|
||
|
||
New step 4 after vault creation: enter device name (defaults to
|
||
"Chrome on Linux" based on detected browser/OS). Generates ed25519
|
||
keypair, stores private key in chrome.storage.local, registers
|
||
device with vault.
|
||
|
||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Manual browser testing
|
||
|
||
**No files to modify — manual verification.**
|
||
|
||
- [ ] **Step 1: Build extension**
|
||
|
||
Run: `cd extension && bun run build`
|
||
|
||
- [ ] **Step 2: Load in Chrome**
|
||
|
||
1. Open `chrome://extensions`
|
||
2. Enable Developer mode
|
||
3. Load unpacked from `extension/dist`
|
||
|
||
- [ ] **Step 3: Test setup wizard device step (new vault)**
|
||
|
||
1. Click extension icon → should open setup.html
|
||
2. Complete steps 1-3 (host, connection, create vault)
|
||
3. **New step 4**: Device name input should appear with default like "Chrome on Linux"
|
||
4. Edit name or accept default → Continue
|
||
5. Step 5 (finish) should appear
|
||
6. Download reference image, save config to extension
|
||
|
||
- [ ] **Step 4: Test device list**
|
||
|
||
1. Unlock vault
|
||
2. Settings → Devices
|
||
3. Should show current device with "← you" indicator
|
||
4. Should not have revoke button on current device
|
||
|
||
- [ ] **Step 5: Test trash flow**
|
||
|
||
1. Create a test login item
|
||
2. Open item detail → Edit → Trash
|
||
3. Settings → Trash → should see trashed item
|
||
4. Click restore → item should return to main list
|
||
5. Trash again → Empty trash → item should be permanently deleted
|
||
|
||
- [ ] **Step 6: Test field history**
|
||
|
||
1. Create login item with password
|
||
2. Edit item, change password, save
|
||
3. Open item detail → "View history" button should appear
|
||
4. Click it → should see current + previous password (masked)
|
||
5. Click entry to reveal
|
||
6. Copy button should work
|
||
|
||
- [ ] **Step 7: Test attachment cap setting**
|
||
|
||
1. Settings → Vault Settings
|
||
2. Should see "max file size" dropdown under "attachments"
|
||
3. Change value, save
|
||
4. Lock/unlock → setting should persist
|
||
|
||
- [ ] **Step 8: Build Firefox extension**
|
||
|
||
Run: `cd extension && bun run build:firefox`
|
||
|
||
- [ ] **Step 9: Repeat tests in Firefox**
|
||
|
||
Load extension in Firefox and repeat steps 3-7.
|
||
|
||
- [ ] **Step 10: Record results and commit any fixes**
|
||
|
||
If all tests pass, tag the completion:
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git tag plan-1c-gamma2-complete
|
||
```
|
||
|
||
---
|
||
|
||
## Commit strategy
|
||
|
||
Direct to `main` per project convention. Each task = one commit. Do NOT push.
|
||
|
||
Tag `plan-1c-gamma2-complete` after all tasks pass + manual tests verified.
|