feat(ext/vault): scaffold vault.html tab with sidebar+pane layout and hash routing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,5 +30,10 @@
|
|||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
},
|
},
|
||||||
|
"commands": {
|
||||||
|
"open-vault": {
|
||||||
|
"description": "Open relicario vault"
|
||||||
|
}
|
||||||
|
},
|
||||||
"web_accessible_resources": []
|
"web_accessible_resources": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ chrome.storage.local.get('session_timeout').then((r) => {
|
|||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
chrome.commands.onCommand.addListener((command) => {
|
||||||
|
if (command === 'open-vault') {
|
||||||
|
chrome.tabs.create({ url: chrome.runtime.getURL('vault.html') });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener(
|
chrome.runtime.onMessage.addListener(
|
||||||
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
1308
extension/src/vault/vault.css
Normal file
1308
extension/src/vault/vault.css
Normal file
File diff suppressed because it is too large
Load Diff
12
extension/src/vault/vault.html
Normal file
12
extension/src/vault/vault.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!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>
|
||||||
848
extension/src/vault/vault.ts
Normal file
848
extension/src/vault/vault.ts
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
/// Vault tab entry point — full "desktop-like" sidebar + pane layout.
|
||||||
|
///
|
||||||
|
/// This is a standalone entry point with its own state and renderers.
|
||||||
|
/// Task 4 will wire shared popup components; for now all pane renderers
|
||||||
|
/// are placeholder implementations.
|
||||||
|
|
||||||
|
import type { Request, Response } from '../shared/messages';
|
||||||
|
import type {
|
||||||
|
ItemId, ItemType, ManifestEntry, Item, VaultSettings,
|
||||||
|
} from '../shared/types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function sendMessage(request: Request): Promise<Response> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.runtime.sendMessage(request, (response: Response) => {
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeIcon(t: ItemType): string {
|
||||||
|
switch (t) {
|
||||||
|
case 'login': return '\u{1F511}'; // key
|
||||||
|
case 'secure_note': return '\u{1F4DD}'; // memo
|
||||||
|
case 'identity': return '\u{1FAAA}'; // id card
|
||||||
|
case 'card': return '\u{1F4B3}'; // credit card
|
||||||
|
case 'key': return '\u{1F5DD}'; // old key
|
||||||
|
case 'document': return '\u{1F4C4}'; // page facing up
|
||||||
|
case 'totp': return '⏱'; // stopwatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(t: ItemType): string {
|
||||||
|
switch (t) {
|
||||||
|
case 'login': return 'Logins';
|
||||||
|
case 'secure_note': return 'Secure Notes';
|
||||||
|
case 'identity': return 'Identities';
|
||||||
|
case 'card': return 'Cards';
|
||||||
|
case 'key': return 'Keys';
|
||||||
|
case 'document': return 'Documents';
|
||||||
|
case 'totp': return 'TOTP';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hash routing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings';
|
||||||
|
|
||||||
|
interface HashRoute {
|
||||||
|
view: VaultView;
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHash(): HashRoute {
|
||||||
|
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||||
|
if (!raw) return { view: 'list' };
|
||||||
|
|
||||||
|
const parts = raw.split('/');
|
||||||
|
const view = parts[0] as VaultView;
|
||||||
|
|
||||||
|
switch (view) {
|
||||||
|
case 'detail':
|
||||||
|
case 'edit':
|
||||||
|
return { view, id: parts[1] };
|
||||||
|
case 'add':
|
||||||
|
return { view, type: parts[1] };
|
||||||
|
case 'trash':
|
||||||
|
case 'devices':
|
||||||
|
case 'settings':
|
||||||
|
return { view };
|
||||||
|
default:
|
||||||
|
return { view: 'list' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHash(view: VaultView, param?: string): void {
|
||||||
|
const fragment = param ? `${view}/${param}` : view;
|
||||||
|
window.location.hash = fragment === 'list' ? '' : fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface VaultState {
|
||||||
|
unlocked: boolean;
|
||||||
|
entries: Array<[ItemId, ManifestEntry]>;
|
||||||
|
selectedId: ItemId | null;
|
||||||
|
selectedItem: Item | null;
|
||||||
|
searchQuery: string;
|
||||||
|
vaultSettings: VaultSettings | null;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: VaultState = {
|
||||||
|
unlocked: false,
|
||||||
|
entries: [],
|
||||||
|
selectedId: null,
|
||||||
|
selectedItem: null,
|
||||||
|
searchQuery: '',
|
||||||
|
vaultSettings: null,
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
const app = document.getElementById('vault-app');
|
||||||
|
if (!app) return;
|
||||||
|
|
||||||
|
if (!state.unlocked) {
|
||||||
|
renderLockScreen(app);
|
||||||
|
} else {
|
||||||
|
renderShell(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lock screen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderLockScreen(app: HTMLElement): void {
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="vault-lock-screen">
|
||||||
|
<span class="brand">relicario</span>
|
||||||
|
<div class="vault-lock-screen__form">
|
||||||
|
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
|
||||||
|
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
|
||||||
|
${state.error ? `<div class="error" style="text-align:center;">${escapeHtml(state.error)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = document.getElementById('vault-passphrase') as HTMLInputElement;
|
||||||
|
const btn = document.getElementById('vault-unlock-btn')!;
|
||||||
|
|
||||||
|
const doUnlock = async () => {
|
||||||
|
const passphrase = input.value;
|
||||||
|
if (!passphrase) return;
|
||||||
|
btn.textContent = 'unlocking...';
|
||||||
|
btn.setAttribute('disabled', 'true');
|
||||||
|
const resp = await sendMessage({ type: 'unlock', passphrase });
|
||||||
|
if (resp.ok) {
|
||||||
|
state.unlocked = true;
|
||||||
|
state.error = null;
|
||||||
|
await loadManifest();
|
||||||
|
render();
|
||||||
|
} else {
|
||||||
|
state.error = resp.error ?? 'unlock failed';
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
btn.addEventListener('click', doUnlock);
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') doUnlock();
|
||||||
|
});
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shell (sidebar + pane)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderShell(app: HTMLElement): void {
|
||||||
|
// Only create the shell structure if it's not present yet
|
||||||
|
if (!app.querySelector('.vault-sidebar')) {
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="vault-sidebar">
|
||||||
|
<div class="vault-sidebar__header">
|
||||||
|
<span class="brand">relicario</span>
|
||||||
|
</div>
|
||||||
|
<div class="vault-sidebar__search">
|
||||||
|
<input type="text" id="vault-search" placeholder="/ search..." />
|
||||||
|
</div>
|
||||||
|
<div class="vault-sidebar__list" id="vault-sidebar-list"></div>
|
||||||
|
<div class="vault-sidebar__nav">
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="add">+ new item</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="trash">\u{1F5D1} trash</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="devices">\u{1F4F1} devices</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="settings">⚙ settings</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="lock">\u{1F512} lock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vault-pane vault-pane--empty" id="vault-pane">
|
||||||
|
select an item
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
wireSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSidebarList();
|
||||||
|
renderPane();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar wiring
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wireSidebar(): void {
|
||||||
|
// Search
|
||||||
|
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
||||||
|
searchInput?.addEventListener('input', () => {
|
||||||
|
state.searchQuery = searchInput.value;
|
||||||
|
renderSidebarList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nav buttons
|
||||||
|
document.querySelectorAll('.vault-sidebar__nav-item').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const nav = (btn as HTMLElement).dataset.nav;
|
||||||
|
if (nav === 'lock') {
|
||||||
|
await sendMessage({ type: 'lock' });
|
||||||
|
state.unlocked = false;
|
||||||
|
state.selectedId = null;
|
||||||
|
state.selectedItem = null;
|
||||||
|
state.entries = [];
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nav === 'add') {
|
||||||
|
state.selectedId = null;
|
||||||
|
state.selectedItem = null;
|
||||||
|
setHash('add');
|
||||||
|
renderPane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
|
||||||
|
state.selectedId = null;
|
||||||
|
state.selectedItem = null;
|
||||||
|
setHash(nav);
|
||||||
|
renderPane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global "/" shortcut to focus search
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === '/' && !isEditableTarget(e.target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchInput?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||||||
|
let filtered = state.entries.filter(
|
||||||
|
([, e]) => e.trashed_at === undefined || e.trashed_at === null,
|
||||||
|
);
|
||||||
|
if (state.searchQuery) {
|
||||||
|
const q = state.searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(([, e]) => {
|
||||||
|
if (e.title.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.icon_hint?.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.group?.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filtered.sort((a, b) => a[1].title.localeCompare(b[1].title));
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebarList(): void {
|
||||||
|
const container = document.getElementById('vault-sidebar-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const filtered = getFilteredEntries();
|
||||||
|
|
||||||
|
// Group by type
|
||||||
|
const groups = new Map<ItemType, Array<[ItemId, ManifestEntry]>>();
|
||||||
|
for (const entry of filtered) {
|
||||||
|
const t = entry[1].type;
|
||||||
|
if (!groups.has(t)) groups.set(t, []);
|
||||||
|
groups.get(t)!.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty">no items</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
// Stable type ordering
|
||||||
|
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
|
||||||
|
for (const t of typeOrder) {
|
||||||
|
const items = groups.get(t);
|
||||||
|
if (!items || items.length === 0) continue;
|
||||||
|
html += `<div class="vault-group-header">${typeIcon(t)} ${escapeHtml(typeLabel(t))}</div>`;
|
||||||
|
for (const [id, e] of items) {
|
||||||
|
const sel = id === state.selectedId ? ' selected' : '';
|
||||||
|
const meta = e.icon_hint ? escapeHtml(e.icon_hint) : '';
|
||||||
|
html += `
|
||||||
|
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
|
||||||
|
<span class="vault-entry__title">${escapeHtml(e.title)}</span>
|
||||||
|
${meta ? `<span class="vault-entry__meta">${meta}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Wire clicks
|
||||||
|
container.querySelectorAll('.vault-entry').forEach((el) => {
|
||||||
|
el.addEventListener('click', async () => {
|
||||||
|
const id = (el as HTMLElement).dataset.id!;
|
||||||
|
await selectItem(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectItem(id: ItemId): Promise<void> {
|
||||||
|
state.loading = true;
|
||||||
|
const resp = await sendMessage({ type: 'get_item', id });
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { item: Item };
|
||||||
|
state.selectedId = id;
|
||||||
|
state.selectedItem = data.item;
|
||||||
|
state.loading = false;
|
||||||
|
setHash('detail', id);
|
||||||
|
renderSidebarList();
|
||||||
|
renderPane();
|
||||||
|
} else {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = (resp as { error: string }).error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pane rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderPane(): void {
|
||||||
|
const pane = document.getElementById('vault-pane');
|
||||||
|
if (!pane) return;
|
||||||
|
|
||||||
|
const route = parseHash();
|
||||||
|
|
||||||
|
switch (route.view) {
|
||||||
|
case 'detail':
|
||||||
|
renderDetailPane(pane);
|
||||||
|
break;
|
||||||
|
case 'add':
|
||||||
|
renderAddPane(pane, route.type);
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
renderEditPane(pane);
|
||||||
|
break;
|
||||||
|
case 'trash':
|
||||||
|
renderTrashPane(pane);
|
||||||
|
break;
|
||||||
|
case 'devices':
|
||||||
|
renderDevicesPane(pane);
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
renderSettingsPane(pane);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
renderEmptyPane(pane);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyPane(pane: HTMLElement): void {
|
||||||
|
pane.className = 'vault-pane vault-pane--empty';
|
||||||
|
pane.innerHTML = 'select an item';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail pane (placeholder — Task 4 wires real popup components)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderDetailPane(pane: HTMLElement): void {
|
||||||
|
pane.className = 'vault-pane';
|
||||||
|
const item = state.selectedItem;
|
||||||
|
if (!item) {
|
||||||
|
renderEmptyPane(pane);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fieldsHtml = '';
|
||||||
|
|
||||||
|
// Core fields based on type
|
||||||
|
switch (item.core.type) {
|
||||||
|
case 'login': {
|
||||||
|
const c = item.core;
|
||||||
|
if (c.username) fieldsHtml += fieldRow('username', c.username);
|
||||||
|
if (c.password) fieldsHtml += fieldRow('password', '••••••••', true);
|
||||||
|
if (c.url) fieldsHtml += fieldRow('url', c.url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'secure_note': {
|
||||||
|
fieldsHtml += fieldRow('body', item.core.body);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'identity': {
|
||||||
|
const c = item.core;
|
||||||
|
if (c.full_name) fieldsHtml += fieldRow('name', c.full_name);
|
||||||
|
if (c.email) fieldsHtml += fieldRow('email', c.email);
|
||||||
|
if (c.phone) fieldsHtml += fieldRow('phone', c.phone);
|
||||||
|
if (c.address) fieldsHtml += fieldRow('address', c.address);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'card': {
|
||||||
|
const c = item.core;
|
||||||
|
if (c.number) fieldsHtml += fieldRow('number', '•••• ' + c.number.slice(-4));
|
||||||
|
if (c.holder) fieldsHtml += fieldRow('holder', c.holder);
|
||||||
|
if (c.expiry) fieldsHtml += fieldRow('expiry', `${c.expiry.month}/${c.expiry.year}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'key': {
|
||||||
|
const c = item.core;
|
||||||
|
if (c.label) fieldsHtml += fieldRow('label', c.label);
|
||||||
|
if (c.algorithm) fieldsHtml += fieldRow('algorithm', c.algorithm);
|
||||||
|
fieldsHtml += fieldRow('key', '••••••••', true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'document': {
|
||||||
|
const c = item.core;
|
||||||
|
fieldsHtml += fieldRow('filename', c.filename);
|
||||||
|
fieldsHtml += fieldRow('mime', c.mime_type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'totp': {
|
||||||
|
const c = item.core;
|
||||||
|
if (c.issuer) fieldsHtml += fieldRow('issuer', c.issuer);
|
||||||
|
if (c.label) fieldsHtml += fieldRow('label', c.label);
|
||||||
|
fieldsHtml += fieldRow('digits', String(c.config.digits));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom sections
|
||||||
|
if (item.sections.length > 0) {
|
||||||
|
for (const section of item.sections) {
|
||||||
|
const sectionName = section.name || '(unnamed section)';
|
||||||
|
fieldsHtml += `<div class="section-header">${escapeHtml(sectionName)}</div>`;
|
||||||
|
for (const field of section.fields) {
|
||||||
|
const val = field.value.kind === 'month_year'
|
||||||
|
? `${(field.value.value as { month: number; year: number }).month}/${(field.value.value as { month: number; year: number }).year}`
|
||||||
|
: String(field.value.value);
|
||||||
|
const hidden = field.hidden_by_default || field.kind === 'password' || field.kind === 'concealed';
|
||||||
|
fieldsHtml += fieldRow(field.label, hidden ? '••••••••' : val, hidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
if (item.notes) {
|
||||||
|
fieldsHtml += `<div class="section-header">notes</div>`;
|
||||||
|
fieldsHtml += `<div class="field-row"><div class="field-row__label"></div><div class="field-row__value"><pre>${escapeHtml(item.notes)}</pre></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modified = new Date(item.modified * 1000).toLocaleDateString();
|
||||||
|
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="detail-header" style="padding:0 0 12px; border-bottom:1px solid #21262d; margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<span style="font-size:18px; margin-right:8px;">${typeIcon(item.type)}</span>
|
||||||
|
<span class="detail-title">${escapeHtml(item.title)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button class="btn" id="pane-edit-btn">edit</button>
|
||||||
|
<button class="btn btn-danger" id="pane-delete-btn">delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-block sig-block--gold">
|
||||||
|
${fieldsHtml}
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:16px;">modified ${escapeHtml(modified)}</div>
|
||||||
|
${item.tags.length > 0 ? `<div class="muted" style="margin-top:4px;">tags: ${item.tags.map(t => escapeHtml(t)).join(', ')}</div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('pane-edit-btn')?.addEventListener('click', () => {
|
||||||
|
setHash('edit', state.selectedId!);
|
||||||
|
renderPane();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pane-delete-btn')?.addEventListener('click', async () => {
|
||||||
|
if (!state.selectedId) return;
|
||||||
|
const resp = await sendMessage({ type: 'delete_item', id: state.selectedId });
|
||||||
|
if (resp.ok) {
|
||||||
|
state.selectedId = null;
|
||||||
|
state.selectedItem = null;
|
||||||
|
await loadManifest();
|
||||||
|
setHash('list');
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldRow(label: string, value: string, concealed = false): string {
|
||||||
|
return `
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-row__label">${escapeHtml(label)}</div>
|
||||||
|
<div class="field-row__value${concealed ? '' : ''}">${escapeHtml(value)}</div>
|
||||||
|
<div class="field-row__actions">
|
||||||
|
<button onclick="navigator.clipboard.writeText(this.closest('.field-row').querySelector('.field-row__value').textContent.trim())" title="copy">copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add pane (placeholder)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderAddPane(pane: HTMLElement, itemType?: string): void {
|
||||||
|
pane.className = 'vault-pane';
|
||||||
|
|
||||||
|
if (!itemType) {
|
||||||
|
// Show type picker
|
||||||
|
const types: Array<{ type: ItemType; icon: string; label: string }> = [
|
||||||
|
{ type: 'login', icon: '\u{1F511}', label: 'Login' },
|
||||||
|
{ type: 'secure_note', icon: '\u{1F4DD}', label: 'Secure Note' },
|
||||||
|
{ type: 'identity', icon: '\u{1FAAA}', label: 'Identity' },
|
||||||
|
{ type: 'card', icon: '\u{1F4B3}', label: 'Card' },
|
||||||
|
{ type: 'key', icon: '\u{1F5DD}', label: 'Key' },
|
||||||
|
{ type: 'document', icon: '\u{1F4C4}', label: 'Document' },
|
||||||
|
{ type: 'totp', icon: '⏱', label: 'TOTP' },
|
||||||
|
];
|
||||||
|
pane.innerHTML = `
|
||||||
|
<h3 style="margin-bottom:16px; font-size:15px;">new item</h3>
|
||||||
|
<div class="type-select-list">
|
||||||
|
${types.map(t => `
|
||||||
|
<button class="type-select-row" data-type="${t.type}">
|
||||||
|
<span class="type-select-icon">${t.icon}</span>
|
||||||
|
${escapeHtml(t.label)}
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
pane.querySelectorAll('.type-select-row').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const t = (btn as HTMLElement).dataset.type!;
|
||||||
|
setHash('add', t);
|
||||||
|
renderPane();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder form — Task 4 will wire real popup components
|
||||||
|
pane.innerHTML = `
|
||||||
|
<h3 style="margin-bottom:16px; font-size:15px;">
|
||||||
|
${typeIcon(itemType as ItemType)} new ${escapeHtml(itemType)}
|
||||||
|
</h3>
|
||||||
|
<p class="muted" style="margin-bottom:16px;">
|
||||||
|
Full form will be wired in Task 4 (shared state host).
|
||||||
|
</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" id="pane-back-btn">back</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('pane-back-btn')?.addEventListener('click', () => {
|
||||||
|
setHash('list');
|
||||||
|
renderPane();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Edit pane (placeholder)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderEditPane(pane: HTMLElement): void {
|
||||||
|
pane.className = 'vault-pane';
|
||||||
|
const item = state.selectedItem;
|
||||||
|
if (!item) {
|
||||||
|
renderEmptyPane(pane);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pane.innerHTML = `
|
||||||
|
<h3 style="margin-bottom:16px; font-size:15px;">
|
||||||
|
${typeIcon(item.type)} edit: ${escapeHtml(item.title)}
|
||||||
|
</h3>
|
||||||
|
<p class="muted" style="margin-bottom:16px;">
|
||||||
|
Full edit form will be wired in Task 4 (shared state host).
|
||||||
|
</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" id="pane-cancel-btn">cancel</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('pane-cancel-btn')?.addEventListener('click', () => {
|
||||||
|
setHash('detail', state.selectedId!);
|
||||||
|
renderPane();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trash pane (placeholder)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderTrashPane(pane: HTMLElement): void {
|
||||||
|
pane.className = 'vault-pane';
|
||||||
|
|
||||||
|
const trashedEntries = state.entries.filter(
|
||||||
|
([, e]) => e.trashed_at !== undefined && e.trashed_at !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="trash-header">
|
||||||
|
<button class="btn" id="pane-trash-back">←</button>
|
||||||
|
<h3 style="font-size:15px;">\u{1F5D1} trash</h3>
|
||||||
|
</div>
|
||||||
|
${trashedEntries.length === 0
|
||||||
|
? '<div class="empty">trash is empty</div>'
|
||||||
|
: trashedEntries.map(([id, e]) => `
|
||||||
|
<div class="trash-row">
|
||||||
|
<span class="trash-row__icon">${typeIcon(e.type)}</span>
|
||||||
|
<div class="trash-row__info">
|
||||||
|
<span class="trash-row__title">${escapeHtml(e.title)}</span>
|
||||||
|
<span class="trash-row__meta">${e.type}</span>
|
||||||
|
</div>
|
||||||
|
<button class="trash-row__restore" data-id="${escapeHtml(id)}">restore</button>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('pane-trash-back')?.addEventListener('click', () => {
|
||||||
|
setHash('list');
|
||||||
|
renderPane();
|
||||||
|
});
|
||||||
|
|
||||||
|
pane.querySelectorAll('.trash-row__restore').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = (btn as HTMLElement).dataset.id!;
|
||||||
|
const resp = await sendMessage({ type: 'restore_item', id });
|
||||||
|
if (resp.ok) {
|
||||||
|
await loadManifest();
|
||||||
|
renderSidebarList();
|
||||||
|
renderTrashPane(pane);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Devices pane (placeholder)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderDevicesPane(pane: HTMLElement): void {
|
||||||
|
pane.className = 'vault-pane';
|
||||||
|
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="devices-header">
|
||||||
|
<button class="btn" id="pane-devices-back">←</button>
|
||||||
|
<h3 style="font-size:15px;">\u{1F4F1} devices</h3>
|
||||||
|
</div>
|
||||||
|
<p class="muted">loading devices...</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('pane-devices-back')?.addEventListener('click', () => {
|
||||||
|
setHash('list');
|
||||||
|
renderPane();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch and render devices
|
||||||
|
sendMessage({ type: 'list_devices' }).then((resp) => {
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = resp.data as { devices: Array<{ name: string; public_key: string; added_at: number }> };
|
||||||
|
const devicesContainer = pane.querySelector('.muted');
|
||||||
|
if (!devicesContainer) return;
|
||||||
|
|
||||||
|
if (data.devices.length === 0) {
|
||||||
|
devicesContainer.outerHTML = '<div class="empty">no devices registered</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devicesContainer.outerHTML = data.devices.map((d) => `
|
||||||
|
<div class="device-row">
|
||||||
|
<div class="device-row__info">
|
||||||
|
<span class="device-row__name">${escapeHtml(d.name)}</span>
|
||||||
|
<span class="device-row__meta">added ${new Date(d.added_at * 1000).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings pane (placeholder)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderSettingsPane(pane: HTMLElement): void {
|
||||||
|
pane.className = 'vault-pane';
|
||||||
|
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="settings-header">
|
||||||
|
<button class="btn" id="pane-settings-back">←</button>
|
||||||
|
<h3 style="font-size:15px;">⚙ vault settings</h3>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-bottom:16px;">
|
||||||
|
Full settings view will be wired in Task 4 (shared state host).
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (state.vaultSettings) {
|
||||||
|
const vs = state.vaultSettings;
|
||||||
|
const trashRetention = vs.trash_retention.kind === 'forever'
|
||||||
|
? 'forever'
|
||||||
|
: `${(vs.trash_retention as { kind: 'days'; value: number }).value} days`;
|
||||||
|
const historyRetention = vs.field_history_retention.kind === 'forever'
|
||||||
|
? 'forever'
|
||||||
|
: vs.field_history_retention.kind === 'last_n'
|
||||||
|
? `last ${(vs.field_history_retention as { kind: 'last_n'; value: number }).value}`
|
||||||
|
: `${(vs.field_history_retention as { kind: 'days'; value: number }).value} days`;
|
||||||
|
|
||||||
|
pane.innerHTML += `
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">retention</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">trash</span>
|
||||||
|
<span>${escapeHtml(trashRetention)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">history</span>
|
||||||
|
<span>${escapeHtml(historyRetention)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pane-settings-back')?.addEventListener('click', () => {
|
||||||
|
setHash('list');
|
||||||
|
renderPane();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data loading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const data = vsResp.data as { settings: VaultSettings };
|
||||||
|
state.vaultSettings = data.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deep link from hash
|
||||||
|
const route = parseHash();
|
||||||
|
if (route.view === 'detail' && route.id) {
|
||||||
|
const itemResp = await sendMessage({ type: 'get_item', id: route.id });
|
||||||
|
if (itemResp.ok) {
|
||||||
|
const data = itemResp.data as { item: Item };
|
||||||
|
state.selectedId = route.id;
|
||||||
|
state.selectedItem = data.item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Check if already unlocked
|
||||||
|
const resp = await sendMessage({ type: 'is_unlocked' });
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { unlocked: boolean };
|
||||||
|
if (data.unlocked) {
|
||||||
|
state.unlocked = true;
|
||||||
|
await loadManifest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
// Session expired listener
|
||||||
|
chrome.runtime.onMessage.addListener((msg) => {
|
||||||
|
if (msg.type === 'session_expired') {
|
||||||
|
state.unlocked = false;
|
||||||
|
state.selectedId = null;
|
||||||
|
state.selectedItem = null;
|
||||||
|
state.entries = [];
|
||||||
|
state.error = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hash change listener
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
if (!state.unlocked) return;
|
||||||
|
|
||||||
|
const route = parseHash();
|
||||||
|
|
||||||
|
// If navigating to a detail/edit view for an item we already have loaded
|
||||||
|
if ((route.view === 'detail' || route.view === 'edit') && route.id) {
|
||||||
|
if (state.selectedId === route.id && state.selectedItem) {
|
||||||
|
renderPane();
|
||||||
|
renderSidebarList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Need to fetch the item
|
||||||
|
selectItem(route.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-item views, just re-render the pane
|
||||||
|
state.selectedId = null;
|
||||||
|
state.selectedItem = null;
|
||||||
|
renderSidebarList();
|
||||||
|
renderPane();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ module.exports = {
|
|||||||
popup: './src/popup/popup.ts',
|
popup: './src/popup/popup.ts',
|
||||||
content: './src/content/detector.ts',
|
content: './src/content/detector.ts',
|
||||||
setup: './src/setup/setup.ts',
|
setup: './src/setup/setup.ts',
|
||||||
|
vault: './src/vault/vault.ts',
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve(__dirname, 'dist'),
|
path: path.resolve(__dirname, 'dist'),
|
||||||
@@ -27,6 +28,8 @@ module.exports = {
|
|||||||
{ from: 'src/popup/styles.css', to: 'styles.css' },
|
{ from: 'src/popup/styles.css', to: 'styles.css' },
|
||||||
{ from: 'setup.html', to: '.' },
|
{ from: 'setup.html', to: '.' },
|
||||||
{ from: 'icons', to: 'icons' },
|
{ from: 'icons', to: 'icons' },
|
||||||
|
{ from: 'src/vault/vault.html', to: 'vault.html' },
|
||||||
|
{ from: 'src/vault/vault.css', to: 'vault.css' },
|
||||||
{ from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
|
{ from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
|
||||||
{ from: 'wasm/relicario_wasm.js', to: '.' },
|
{ from: 'wasm/relicario_wasm.js', to: '.' },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user