Files
relicario/extension/src/vault/vault.ts
adlee-was-taken b270dfedb4 feat(ext/vault): sticky save bar in fullscreen forms
The form pane gets a flex column layout: scrollable content above,
sticky save bar at bottom. Bar uses translucent fill with backdrop-blur
and a 24px gradient fade so content scrolls under it. Save / cancel
buttons reuse the form's existing handlers via externalActions flag.
2026-05-02 15:05:09 -04:00

644 lines
20 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 { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } 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';
// ---------------------------------------------------------------------------
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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' | '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;
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,
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);
renderSidebarList();
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>
${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">${GLYPH_TRASH} trash</button>
<button class="vault-sidebar__nav-item" data-nav="devices">${GLYPH_DEVICES} devices</button>
<button class="vault-sidebar__nav-item" data-nav="settings">${GLYPH_SETTINGS} settings</button>
<button class="vault-sidebar__nav-item" data-nav="lock">${GLYPH_LOCK} 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;
state.newType = null;
setHash('add');
renderPane();
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
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;
}
}
// ---------------------------------------------------------------------------
// 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 typeLabel = itemType.replace('_', ' ');
const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`;
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 () => {
// 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();
});
});