959 lines
33 KiB
TypeScript
959 lines
33 KiB
TypeScript
/// Vault tab entry point — full "desktop-like" sidebar + pane layout.
|
|
///
|
|
/// Registers as the shared state host so popup components (item-detail,
|
|
/// item-form, trash, devices, settings, etc.) render natively in the
|
|
/// vault tab's pane area.
|
|
|
|
import type { Request, Response } from '../shared/messages';
|
|
import type {
|
|
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
|
|
} from '../shared/types';
|
|
import { registerHost } from '../shared/state';
|
|
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
|
|
import {
|
|
GLYPH_TRASH, GLYPH_SETTINGS, GLYPH_LOCK,
|
|
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
|
|
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
|
|
} from '../shared/glyphs';
|
|
import { renderItemDetail } from '../popup/components/item-detail';
|
|
import { renderItemForm } from '../popup/components/item-form';
|
|
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
|
|
import { renderDevices, teardown as teardownDevices } from '../popup/components/devices';
|
|
import { renderSettings } from '../popup/components/settings';
|
|
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
|
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
|
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
|
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
|
import { applyColorScheme } from '../shared/color-scheme';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bottom sheet type picker
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const BOTTOM_SHEET_TYPES: Array<{ type: ItemType; label: string }> = [
|
|
{ type: 'login', label: 'Login' },
|
|
{ type: 'secure_note', label: 'Secure Note' },
|
|
{ type: 'totp', label: 'TOTP' },
|
|
{ type: 'card', label: 'Card' },
|
|
{ type: 'identity', label: 'Identity' },
|
|
{ type: 'key', label: 'SSH / API Key' },
|
|
{ type: 'document', label: 'Document' },
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 renderErrorBlock(code: string | null | undefined): string {
|
|
if (!code) return '';
|
|
const copy = lookupErrorCopy(code);
|
|
const ctaHtml = copy.cta
|
|
? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
|
|
: '';
|
|
return `
|
|
<div class="error error-block">
|
|
<div class="error-title">${escapeHtml(copy.title)}</div>
|
|
<div class="error-body">${escapeHtml(copy.body)}</div>
|
|
${ctaHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function typeIcon(t: ItemType): string {
|
|
switch (t) {
|
|
case 'login': return GLYPH_TYPE_LOGIN;
|
|
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
|
case 'identity': return GLYPH_TYPE_IDENTITY;
|
|
case 'card': return GLYPH_TYPE_CARD;
|
|
case 'key': return GLYPH_TYPE_KEY;
|
|
case 'document': return GLYPH_TYPE_DOCUMENT;
|
|
case 'totp': return GLYPH_TYPE_TOTP;
|
|
}
|
|
}
|
|
|
|
function typeLabel(t: ItemType): string {
|
|
const labels: Record<ItemType, string> = {
|
|
login: 'Login',
|
|
secure_note: 'Secure Note',
|
|
identity: 'Identity',
|
|
card: 'Card',
|
|
key: 'SSH / API Key',
|
|
document: 'Document',
|
|
totp: 'TOTP',
|
|
};
|
|
return labels[t];
|
|
}
|
|
|
|
function relativeTime(unixSec: number): string {
|
|
const diffS = Math.floor(Date.now() / 1000) - unixSec;
|
|
if (diffS < 60) return 'just now';
|
|
if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`;
|
|
if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`;
|
|
return `${Math.floor(diffS / 86400)}d ago`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hash routing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup' | 'import';
|
|
|
|
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':
|
|
case 'settings-vault':
|
|
case 'field-history':
|
|
case 'backup':
|
|
case 'import':
|
|
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;
|
|
view: VaultView;
|
|
entries: Array<[ItemId, ManifestEntry]>;
|
|
selectedId: ItemId | null;
|
|
selectedItem: Item | null;
|
|
selectedIndex: number;
|
|
searchQuery: string;
|
|
activeGroup: string | null;
|
|
drawerOpen: boolean;
|
|
bottomSheetOpen: boolean;
|
|
vaultSettings: VaultSettings | null;
|
|
generatorDefaults: GeneratorRequest | null;
|
|
error: string | null;
|
|
loading: boolean;
|
|
newType: ItemType | null;
|
|
capturedTabId: number | null;
|
|
capturedUrl: string;
|
|
historyItemId: ItemId | null;
|
|
}
|
|
|
|
const state: VaultState = {
|
|
unlocked: false,
|
|
view: 'list',
|
|
entries: [],
|
|
selectedId: null,
|
|
selectedItem: null,
|
|
selectedIndex: 0,
|
|
searchQuery: '',
|
|
activeGroup: null,
|
|
drawerOpen: false,
|
|
bottomSheetOpen: false,
|
|
vaultSettings: null,
|
|
generatorDefaults: null,
|
|
error: null,
|
|
loading: false,
|
|
newType: null,
|
|
capturedTabId: null,
|
|
capturedUrl: '',
|
|
historyItemId: null,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Register as shared state host
|
|
// ---------------------------------------------------------------------------
|
|
|
|
registerHost({
|
|
getState: () => state,
|
|
setState: (partial: any) => {
|
|
Object.assign(state, partial);
|
|
renderPane();
|
|
},
|
|
navigate: (view: string, extras?: any) => {
|
|
Object.assign(state, { view, error: null, loading: false, ...extras });
|
|
setHash(view as VaultView);
|
|
renderSidebarCategories();
|
|
renderListPane();
|
|
renderPane();
|
|
},
|
|
sendMessage,
|
|
escapeHtml,
|
|
popOutToTab: () => {},
|
|
isInTab: () => true,
|
|
openVaultTab: () => {},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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>
|
|
${renderErrorBlock(state.error)}
|
|
</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 (3-column: sidebar + list pane + drawer)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderShell(app: HTMLElement): void {
|
|
if (!app.querySelector('.vault-shell')) {
|
|
app.innerHTML = `
|
|
<div class="vault-shell">
|
|
<div class="vault-sidebar">
|
|
<div class="vault-sidebar__header">
|
|
<img class="brand-logo" src="icons/relicario-logo-16.svg" alt="">
|
|
<span class="brand">Relicario</span>
|
|
</div>
|
|
<div class="vault-sidebar__search">
|
|
<input type="text" id="vault-search" placeholder="/ search…" />
|
|
</div>
|
|
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
|
|
<div class="vault-sidebar__nav">
|
|
<button class="vault-sidebar__nav-item" data-nav="add" title="New item">+ new item</button>
|
|
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
|
|
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
|
|
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
|
|
</div>
|
|
</div>
|
|
<div class="vault-list-pane" id="vault-list-pane"></div>
|
|
<div class="vault-drawer" id="vault-drawer"></div>
|
|
<div class="vault-bottom-sheet-scrim" id="vault-sheet-scrim"></div>
|
|
<div class="vault-bottom-sheet" id="vault-bottom-sheet"></div>
|
|
</div>
|
|
`;
|
|
wireSidebar();
|
|
wireBottomSheet();
|
|
}
|
|
|
|
renderSidebarCategories();
|
|
renderListPane();
|
|
if (state.drawerOpen && state.selectedItem) {
|
|
renderDrawer(state.selectedItem);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bottom sheet (wired in Task 11)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function wireBottomSheet(): void {
|
|
document.getElementById('vault-sheet-scrim')?.addEventListener('click', closeBottomSheet);
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && state.bottomSheetOpen) closeBottomSheet();
|
|
});
|
|
}
|
|
|
|
function openBottomSheet(): void {
|
|
const sheet = document.getElementById('vault-bottom-sheet');
|
|
const scrim = document.getElementById('vault-sheet-scrim');
|
|
if (!sheet || !scrim) return;
|
|
|
|
sheet.innerHTML = `
|
|
<div class="vault-bottom-sheet__handle"></div>
|
|
<div class="vault-bottom-sheet__title">New item — choose type</div>
|
|
<div class="vault-type-grid">
|
|
${BOTTOM_SHEET_TYPES.map((t) => `
|
|
<button class="vault-type-card" data-type="${t.type}">
|
|
<span class="vault-type-card__icon" aria-hidden="true">${typeIcon(t.type)}</span>
|
|
<span class="vault-type-card__name">${escapeHtml(t.label)}</span>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
sheet.classList.add('vault-bottom-sheet--open');
|
|
scrim.classList.add('vault-bottom-sheet-scrim--visible');
|
|
state.bottomSheetOpen = true;
|
|
|
|
sheet.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const type = btn.dataset.type as ItemType;
|
|
closeBottomSheet();
|
|
setHash('add', type);
|
|
renderPane();
|
|
});
|
|
});
|
|
}
|
|
|
|
function closeBottomSheet(): void {
|
|
document.getElementById('vault-bottom-sheet')?.classList.remove('vault-bottom-sheet--open');
|
|
document.getElementById('vault-sheet-scrim')?.classList.remove('vault-bottom-sheet-scrim--visible');
|
|
state.bottomSheetOpen = false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Drawer (implemented in Task 10)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function openDrawer(): void {
|
|
document.getElementById('vault-drawer')?.classList.add('vault-drawer--open');
|
|
}
|
|
|
|
function closeDrawer(): void {
|
|
state.drawerOpen = false;
|
|
state.selectedId = null;
|
|
state.selectedItem = null;
|
|
document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open');
|
|
}
|
|
|
|
function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> {
|
|
const core = item.core as Record<string, unknown>;
|
|
if (!core) return [];
|
|
const fields: Array<[string, string, boolean]> = [];
|
|
|
|
switch (item.type) {
|
|
case 'login':
|
|
if ('username' in core) fields.push(['username', String(core.username ?? ''), false]);
|
|
if ('password' in core) fields.push(['password', '••••••••', false]);
|
|
if ('url' in core) fields.push(['url', String(core.url ?? ''), true]);
|
|
break;
|
|
case 'card': {
|
|
if ('number' in core) fields.push(['number', String(core.number ?? ''), false]);
|
|
if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]);
|
|
if ('expiry' in core && core.expiry) {
|
|
const exp = core.expiry as { month: number; year: number };
|
|
fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]);
|
|
}
|
|
if ('cvv' in core) fields.push(['cvv', '•••', false]);
|
|
if ('pin' in core) fields.push(['pin', '••••', false]);
|
|
break;
|
|
}
|
|
case 'identity':
|
|
if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]);
|
|
if ('email' in core) fields.push(['email', String(core.email ?? ''), true]);
|
|
if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]);
|
|
if ('address' in core) fields.push(['address', String(core.address ?? ''), true]);
|
|
if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]);
|
|
break;
|
|
case 'key':
|
|
if ('label' in core) fields.push(['label', String(core.label ?? ''), true]);
|
|
if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]);
|
|
if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]);
|
|
break;
|
|
case 'secure_note':
|
|
if ('body' in core) fields.push(['body', String(core.body ?? ''), true]);
|
|
break;
|
|
case 'totp':
|
|
if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]);
|
|
if ('label' in core) fields.push(['label', String(core.label ?? ''), false]);
|
|
break;
|
|
case 'document':
|
|
if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]);
|
|
if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]);
|
|
break;
|
|
}
|
|
|
|
if (item.notes) fields.push(['notes', item.notes, true]);
|
|
return fields;
|
|
}
|
|
|
|
function renderDrawer(item: Item): void {
|
|
const drawer = document.getElementById('vault-drawer');
|
|
if (!drawer) return;
|
|
|
|
const coreFields = getDrawerCoreFields(item);
|
|
|
|
drawer.innerHTML = `
|
|
<div class="vault-drawer__header">
|
|
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
|
|
<div class="vault-drawer__actions">
|
|
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
|
|
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
|
|
</div>
|
|
</div>
|
|
<div class="vault-drawer__body">
|
|
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
|
|
${item.type === 'login' && (item.core as { url?: string }).url
|
|
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
|
|
: ''}
|
|
<div class="vault-drawer__field-grid">
|
|
${coreFields.map(([label, value, full]) => `
|
|
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
|
|
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
|
|
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('drawer-close-btn')?.addEventListener('click', () => {
|
|
closeDrawer();
|
|
renderListPane();
|
|
});
|
|
|
|
document.getElementById('drawer-edit-btn')?.addEventListener('click', () => {
|
|
if (state.selectedId) {
|
|
setHash('edit', state.selectedId);
|
|
renderPane();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Item selection (implemented in Task 10)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function selectItemForDrawer(id: string): Promise<void> {
|
|
const resp = await sendMessage({ type: 'get_item', id });
|
|
if (!resp.ok) return;
|
|
const data = resp.data as { item: Item };
|
|
state.selectedId = id;
|
|
state.selectedItem = data.item;
|
|
state.drawerOpen = true;
|
|
renderSidebarCategories();
|
|
renderListPane();
|
|
renderDrawer(data.item);
|
|
openDrawer();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sidebar wiring
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function wireSidebar(): void {
|
|
// Search
|
|
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
|
searchInput?.addEventListener('input', () => {
|
|
state.searchQuery = searchInput.value;
|
|
renderSidebarCategories();
|
|
renderListPane();
|
|
});
|
|
|
|
// 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;
|
|
state.newType = null;
|
|
openBottomSheet();
|
|
return;
|
|
}
|
|
if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
|
|
state.selectedId = null;
|
|
state.selectedItem = null;
|
|
state.newType = null;
|
|
setHash(nav);
|
|
renderPane();
|
|
return;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Global "/" shortcut to focus search; Esc to close drawer
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === '/' && !isEditableTarget(e.target)) {
|
|
e.preventDefault();
|
|
searchInput?.focus();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && state.drawerOpen) {
|
|
closeDrawer();
|
|
renderListPane();
|
|
}
|
|
});
|
|
}
|
|
|
|
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 category nav
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 renderSidebarCategories(): void {
|
|
const container = document.getElementById('vault-categories');
|
|
if (!container) return;
|
|
|
|
const filtered = getFilteredEntries();
|
|
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
|
|
|
|
const allCount = filtered.length;
|
|
const isAllActive = !state.activeGroup && state.view === 'list';
|
|
|
|
let html = `
|
|
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
|
|
<span class="vault-category-row__icon">◈</span>
|
|
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
|
|
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
|
|
</button>
|
|
`;
|
|
|
|
for (const t of typeOrder) {
|
|
const count = filtered.filter(([, e]) => e.type === t).length;
|
|
if (count === 0 && allCount > 0) continue;
|
|
const isActive = state.activeGroup === t;
|
|
html += `
|
|
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
|
|
<span class="vault-category-row__icon">${typeIcon(t)}</span>
|
|
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
|
|
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
state.activeGroup = btn.dataset.group || null;
|
|
state.drawerOpen = false;
|
|
state.selectedId = null;
|
|
state.selectedItem = null;
|
|
renderSidebarCategories();
|
|
renderListPane();
|
|
closeDrawer();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List pane
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderListPane(): void {
|
|
const pane = document.getElementById('vault-list-pane');
|
|
if (!pane) return;
|
|
|
|
const group = state.activeGroup as ItemType | null;
|
|
let items = getFilteredEntries();
|
|
if (group) items = items.filter(([, e]) => e.type === group);
|
|
|
|
if (items.length === 0) {
|
|
pane.innerHTML = `
|
|
<div class="empty-state">
|
|
<span class="empty-state__icon" aria-hidden="true">${state.searchQuery ? '⊘' : '◈'}</span>
|
|
<div class="empty-state__title">${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}</div>
|
|
<div class="empty-state__hint">${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
pane.innerHTML = items.map(([id, e]) => {
|
|
const sel = id === state.selectedId ? ' vault-list-row--selected' : '';
|
|
const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : '');
|
|
const modifiedAgo = e.modified ? relativeTime(e.modified) : '';
|
|
return `
|
|
<div class="vault-list-row${sel}" data-id="${escapeHtml(id)}">
|
|
<div class="vault-list-row__icon" aria-hidden="true">${typeIcon(e.type)}</div>
|
|
<div class="vault-list-row__text">
|
|
<div class="vault-list-row__title">${escapeHtml(e.title)}</div>
|
|
${subtitle ? `<div class="vault-list-row__subtitle">${escapeHtml(subtitle)}</div>` : ''}
|
|
</div>
|
|
${modifiedAgo ? `<div class="vault-list-row__age">${escapeHtml(modifiedAgo)}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
pane.querySelectorAll<HTMLElement>('.vault-list-row').forEach((row) => {
|
|
row.addEventListener('click', async () => {
|
|
await selectItemForDrawer(row.dataset.id!);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Platform-aware save hint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const isMac = navigator.platform.toLowerCase().includes('mac');
|
|
const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fullscreen form wrapper — sticky save bar + scrollable content + header
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void {
|
|
const itemType = state.selectedItem?.type ?? state.newType ?? 'login';
|
|
const typeLabelText = itemType.replace('_', ' ');
|
|
const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'form-pane';
|
|
wrapper.innerHTML = `
|
|
<div class="fullscreen-form-header">
|
|
<div>
|
|
<div class="title">${titleText}</div>
|
|
<div class="sub" id="form-dirty-sub">no changes</div>
|
|
</div>
|
|
<div class="hint">${SAVE_HINT}</div>
|
|
</div>
|
|
<div class="form-scroll" id="form-scroll"></div>
|
|
<div class="sticky-save-bar">
|
|
<button class="btn-secondary" id="form-cancel">cancel</button>
|
|
<button class="btn-primary" id="form-save">save</button>
|
|
</div>
|
|
`;
|
|
// Remove pane padding so form-pane can fill height cleanly
|
|
app.style.padding = '0';
|
|
app.style.overflow = 'hidden';
|
|
app.replaceChildren(wrapper);
|
|
|
|
const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement;
|
|
renderItemForm(scrollEl, mode);
|
|
|
|
const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement;
|
|
let isDirty = false;
|
|
const markDirty = () => {
|
|
if (isDirty) return;
|
|
isDirty = true;
|
|
subEl.textContent = 'unsaved · esc to cancel';
|
|
};
|
|
const markClean = () => {
|
|
isDirty = false;
|
|
subEl.textContent = 'no changes';
|
|
};
|
|
scrollEl.addEventListener('input', markDirty, true);
|
|
scrollEl.addEventListener('change', markDirty, true);
|
|
|
|
wrapper.querySelector('#form-cancel')?.addEventListener('click', () => {
|
|
markClean();
|
|
(scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click();
|
|
});
|
|
wrapper.querySelector('#form-save')?.addEventListener('click', () => {
|
|
markClean();
|
|
(scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click();
|
|
});
|
|
}
|
|
|
|
export const __test__ = { renderFormWrapped };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pane rendering — delegates to shared popup components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function teardownPaneComponents(): void {
|
|
teardownTrash();
|
|
teardownDevices();
|
|
teardownFieldHistory();
|
|
teardownBackup();
|
|
teardownImport();
|
|
}
|
|
|
|
function renderPane(): void {
|
|
const pane = document.getElementById('vault-pane');
|
|
if (!pane) return;
|
|
|
|
teardownPaneComponents();
|
|
|
|
const route = parseHash();
|
|
// Keep state.view in sync with hash for components that read it
|
|
state.view = route.view;
|
|
|
|
pane.className = 'vault-pane';
|
|
|
|
switch (route.view) {
|
|
case 'detail':
|
|
if (state.selectedItem) {
|
|
renderItemDetail(pane);
|
|
} else {
|
|
pane.className = 'vault-pane vault-pane--empty';
|
|
pane.innerHTML = 'select an item';
|
|
}
|
|
break;
|
|
case 'add':
|
|
// Prefer hash type for deep-links; otherwise keep the in-memory value
|
|
// set by the type-selection click handler (which calls setState →
|
|
// renderPane before the URL hash has been updated to include the type).
|
|
state.newType = (route.type as ItemType) ?? state.newType ?? null;
|
|
// Use the form wrapper (sticky bar + header) when a type is already chosen.
|
|
// Without a type the type-selection screen renders — no sticky bar needed.
|
|
if (state.newType) {
|
|
renderFormWrapped(pane, 'add');
|
|
} else {
|
|
renderItemForm(pane, 'add');
|
|
}
|
|
break;
|
|
case 'edit':
|
|
renderFormWrapped(pane, 'edit');
|
|
break;
|
|
case 'trash':
|
|
renderTrash(pane);
|
|
break;
|
|
case 'devices':
|
|
renderDevices(pane);
|
|
break;
|
|
case 'settings':
|
|
renderSettings(pane);
|
|
break;
|
|
case 'settings-vault':
|
|
renderVaultSettingsView(pane);
|
|
break;
|
|
case 'field-history':
|
|
renderFieldHistory(pane);
|
|
break;
|
|
case 'backup':
|
|
renderBackupPanel(pane);
|
|
break;
|
|
case 'import':
|
|
renderImportPanel(pane);
|
|
break;
|
|
default:
|
|
pane.className = 'vault-pane vault-pane--empty';
|
|
pane.innerHTML = 'select an item';
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
state.generatorDefaults = data.settings.generator_defaults;
|
|
}
|
|
|
|
// 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 () => {
|
|
await applyColorScheme();
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area === 'sync' && 'password_display_scheme' in changes) {
|
|
void applyColorScheme();
|
|
}
|
|
});
|
|
|
|
// Delegated handler for .error-cta buttons — set up once on the stable root.
|
|
const app = document.getElementById('vault-app')!;
|
|
app.addEventListener('click', (e) => {
|
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.error-cta');
|
|
if (!btn) return;
|
|
const cta = btn.dataset.cta as ErrorCta['action'];
|
|
switch (cta) {
|
|
case 'unlock': {
|
|
document.getElementById('vault-passphrase')?.focus();
|
|
break;
|
|
}
|
|
case 'open_setup': {
|
|
void chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
|
break;
|
|
}
|
|
case 'reload_extension': {
|
|
chrome.runtime.reload();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
renderSidebarCategories();
|
|
renderListPane();
|
|
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;
|
|
renderSidebarCategories();
|
|
renderListPane();
|
|
renderPane();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Legacy selectItem — used by hash-change deep linking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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);
|
|
renderSidebarCategories();
|
|
renderListPane();
|
|
renderPane();
|
|
} else {
|
|
state.loading = false;
|
|
state.error = (resp as { error: string }).error;
|
|
}
|
|
}
|