feat: add popup state machine and all components
View router (setup/locked/list/detail/add/edit), unlock screen with passphrase input, entry list with search/group tabs/keyboard nav, entry detail with TOTP countdown and copy shortcuts, add/edit form with password generation, and 3-step setup wizard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
133
extension/src/popup/popup.ts
Normal file
133
extension/src/popup/popup.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/// Popup entry point — state machine with view routing.
|
||||
///
|
||||
/// Views: setup | locked | list | detail | add | edit
|
||||
/// Navigation works by updating `currentState` and calling `render()`.
|
||||
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
import type { ManifestEntry, Entry } from '../shared/types';
|
||||
import { renderUnlock } from './components/unlock';
|
||||
import { renderEntryList } from './components/entry-list';
|
||||
import { renderEntryDetail } from './components/entry-detail';
|
||||
import { renderEntryForm } from './components/entry-form';
|
||||
import { renderSetupWizard } from './components/setup-wizard';
|
||||
|
||||
// --- Escape HTML to prevent XSS ---
|
||||
export function escapeHtml(str: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
export interface PopupState {
|
||||
view: View;
|
||||
entries: Array<[string, ManifestEntry]>;
|
||||
selectedId: string | null;
|
||||
selectedEntry: Entry | null;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
let currentState: PopupState = {
|
||||
view: 'locked',
|
||||
entries: [],
|
||||
selectedId: null,
|
||||
selectedEntry: null,
|
||||
selectedIndex: 0,
|
||||
searchQuery: '',
|
||||
activeGroup: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export function getState(): PopupState {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
export function setState(partial: Partial<PopupState>): void {
|
||||
currentState = { ...currentState, ...partial };
|
||||
render();
|
||||
}
|
||||
|
||||
// --- Messaging ---
|
||||
|
||||
export function sendMessage(request: Request): Promise<Response> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(request, (response: Response) => {
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
||||
setState({ view, error: null, loading: false, ...extras });
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
|
||||
function render(): void {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
switch (currentState.view) {
|
||||
case 'setup':
|
||||
renderSetupWizard(app);
|
||||
break;
|
||||
case 'locked':
|
||||
renderUnlock(app);
|
||||
break;
|
||||
case 'list':
|
||||
renderEntryList(app);
|
||||
break;
|
||||
case 'detail':
|
||||
renderEntryDetail(app);
|
||||
break;
|
||||
case 'add':
|
||||
renderEntryForm(app, 'add');
|
||||
break;
|
||||
case 'edit':
|
||||
renderEntryForm(app, 'edit');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
|
||||
async function init(): Promise<void> {
|
||||
// Check if extension is configured.
|
||||
const setupResp = await sendMessage({ type: 'get_setup_state' });
|
||||
if (setupResp.ok) {
|
||||
const data = setupResp.data as { isConfigured: boolean };
|
||||
if (!data.isConfigured) {
|
||||
navigate('setup');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if vault is unlocked.
|
||||
const unlockResp = await sendMessage({ type: 'is_unlocked' });
|
||||
if (unlockResp.ok) {
|
||||
const data = unlockResp.data as { unlocked: boolean };
|
||||
if (data.unlocked) {
|
||||
// Load entries and go to list.
|
||||
const listResp = await sendMessage({ type: 'list_entries' });
|
||||
if (listResp.ok) {
|
||||
const listData = listResp.data as { entries: Array<[string, ManifestEntry]> };
|
||||
navigate('list', { entries: listData.entries });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
navigate('locked');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
Reference in New Issue
Block a user