diff --git a/extension/src/__stubs__/relicario_wasm.stub.ts b/extension/src/__stubs__/relicario_wasm.stub.ts new file mode 100644 index 0000000..04d867a --- /dev/null +++ b/extension/src/__stubs__/relicario_wasm.stub.ts @@ -0,0 +1,13 @@ +// Stub for the runtime-only WASM module. Used by vitest so that modules +// importing relicario_wasm.js can be loaded in a Node/happy-dom environment. +// Individual tests that exercise WASM calls should mock the relevant exports. + +export default async function init(): Promise {} +export const unlock = (): never => { throw new Error('wasm stub: unlock not mocked'); }; +export const lock = (): void => {}; +export const manifest_encrypt = (): never => { throw new Error('wasm stub: manifest_encrypt not mocked'); }; +export const manifest_decrypt = (): never => { throw new Error('wasm stub: manifest_decrypt not mocked'); }; +export const settings_encrypt = (): never => { throw new Error('wasm stub: settings_encrypt not mocked'); }; +export const default_vault_settings_json = (): string => '{}'; +export const embed_image_secret = (): never => { throw new Error('wasm stub: embed_image_secret not mocked'); }; +export const register_device = (): never => { throw new Error('wasm stub: register_device not mocked'); }; diff --git a/extension/src/popup/components/__tests__/settings.test.ts b/extension/src/popup/components/__tests__/settings.test.ts index 98262a9..edf3ca4 100644 --- a/extension/src/popup/components/__tests__/settings.test.ts +++ b/extension/src/popup/components/__tests__/settings.test.ts @@ -12,6 +12,22 @@ vi.mock('../../../shared/state', () => ({ })); import { sendMessage } from '../../../shared/state'; +import { DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR } from '../../../shared/color-scheme'; + +function mockChromeStorage(initial: Record = {}) { + const store: Record = { ...initial }; + (global as any).chrome = { + storage: { + sync: { + get: vi.fn((key: string) => Promise.resolve( + key in store ? { [key]: store[key] } : {})), + set: vi.fn((kv: Record) => { Object.assign(store, kv); return Promise.resolve(); }), + remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }), + }, + }, + }; + return store; +} function settingsResponses() { // Two parallel calls in renderSettings: get_settings + get_blacklist. @@ -30,6 +46,7 @@ describe('settings view', () => { }); it('renders a Sync now button', async () => { + mockChromeStorage(); settingsResponses(); await renderSettings(app); @@ -38,6 +55,7 @@ describe('settings view', () => { }); it('clicking Sync now sends a sync message and shows feedback on success', async () => { + mockChromeStorage(); settingsResponses(); (sendMessage as ReturnType).mockResolvedValueOnce({ ok: true }); @@ -52,6 +70,7 @@ describe('settings view', () => { }); it('shows the error when sync fails', async () => { + mockChromeStorage(); settingsResponses(); (sendMessage as ReturnType).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' }); @@ -64,3 +83,109 @@ describe('settings view', () => { expect(status.textContent).toMatch(/remote_unreachable/); }); }); + +describe('settings Display section', () => { + let app: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + (sendMessage as ReturnType).mockReset(); + }); + + it('renders digit and symbol color pickers with default values when storage is empty', async () => { + mockChromeStorage(); + settingsResponses(); + + await renderSettings(app); + + const digitInput = app.querySelector('#display-digit-color'); + const symbolInput = app.querySelector('#display-symbol-color'); + expect(digitInput).not.toBeNull(); + expect(symbolInput).not.toBeNull(); + expect(digitInput!.value).toBe(DEFAULT_DIGIT_COLOR); + expect(symbolInput!.value).toBe(DEFAULT_SYMBOL_COLOR); + }); + + it('renders pickers with stored values when storage has a scheme', async () => { + mockChromeStorage({ + password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' }, + }); + settingsResponses(); + + await renderSettings(app); + + const digitInput = app.querySelector('#display-digit-color'); + const symbolInput = app.querySelector('#display-symbol-color'); + expect(digitInput!.value).toBe('#112233'); + expect(symbolInput!.value).toBe('#aabbcc'); + }); + + it('renders a color-preview-swatch element', async () => { + mockChromeStorage(); + settingsResponses(); + + await renderSettings(app); + + expect(app.querySelector('#display-swatch')).not.toBeNull(); + }); + + it('changing digit color calls saveColorScheme with updated scheme', async () => { + mockChromeStorage(); + settingsResponses(); + + await renderSettings(app); + + const digitInput = app.querySelector('#display-digit-color')!; + digitInput.value = '#ff0000'; + digitInput.dispatchEvent(new Event('change')); + await new Promise((r) => setTimeout(r, 0)); + + const syncSet = (global as any).chrome.storage.sync.set as ReturnType; + expect(syncSet).toHaveBeenCalledWith( + expect.objectContaining({ + password_display_scheme: expect.objectContaining({ digit_color: '#ff0000' }), + }), + ); + }); + + it('changing symbol color calls saveColorScheme with updated scheme', async () => { + mockChromeStorage(); + settingsResponses(); + + await renderSettings(app); + + const symbolInput = app.querySelector('#display-symbol-color')!; + symbolInput.value = '#00ff00'; + symbolInput.dispatchEvent(new Event('change')); + await new Promise((r) => setTimeout(r, 0)); + + const syncSet = (global as any).chrome.storage.sync.set as ReturnType; + expect(syncSet).toHaveBeenCalledWith( + expect.objectContaining({ + password_display_scheme: expect.objectContaining({ symbol_color: '#00ff00' }), + }), + ); + }); + + it('clicking reset calls chrome.storage.sync.remove and restores defaults', async () => { + mockChromeStorage({ + password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' }, + }); + settingsResponses(); + + await renderSettings(app); + + const resetBtn = app.querySelector('#display-reset')!; + resetBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + + const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType; + expect(syncRemove).toHaveBeenCalledWith('password_display_scheme'); + + const digitInput = app.querySelector('#display-digit-color')!; + const symbolInput = app.querySelector('#display-symbol-color')!; + expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR); + expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR); + }); +}); diff --git a/extension/src/popup/components/field-history.ts b/extension/src/popup/components/field-history.ts index a61ee6b..27de410 100644 --- a/extension/src/popup/components/field-history.ts +++ b/extension/src/popup/components/field-history.ts @@ -1,6 +1,7 @@ /// Field history view — shows password/concealed field history for an item. import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; +import { colorizePassword } from '../../shared/password-coloring'; import type { FieldHistoryView } from '../../shared/types'; function relativeTime(unixSec: number): string { @@ -103,6 +104,16 @@ export async function renderFieldHistory(app: HTMLElement): Promise { `; + // Colorize revealed entries: replace plain-text content with colorized spans + app.querySelectorAll('.history-entry__value.revealed').forEach((el) => { + const key = el.closest('.history-entry')?.dataset.entry ?? ''; + const plaintext = valueStore.get(key); + if (plaintext !== undefined) { + el.textContent = ''; + el.appendChild(colorizePassword(plaintext)); + } + }); + // Wire handlers app.querySelector('#back-btn')?.addEventListener('click', () => navigate('detail')); diff --git a/extension/src/popup/components/fields.ts b/extension/src/popup/components/fields.ts index f89c325..e269f6b 100644 --- a/extension/src/popup/components/fields.ts +++ b/extension/src/popup/components/fields.ts @@ -6,6 +6,7 @@ /// copy click handlers on any rendered rows. import { escapeHtml } from '../../shared/state'; +import { colorizePassword } from '../../shared/password-coloring'; import type { Item, Section, Field, FieldValue } from '../../shared/types'; export interface RowOpts { @@ -46,6 +47,7 @@ export interface ConcealedRowOpts { id: string; label: string; value: string; + kind?: 'password' | 'concealed'; monospace?: boolean; multiline?: boolean; } @@ -53,12 +55,15 @@ export interface ConcealedRowOpts { /// Concealed row — value rendered hidden until the user clicks "show". /// Plaintext is stored in `data-field-value` on the row element and copied /// to the visible value span on reveal. Copy button always copies plaintext. +/// When `kind` is "password", wireFieldHandlers applies colorizePassword on +/// reveal so digits/symbols/letters are rendered in distinct colours. export function renderConcealedRow(opts: ConcealedRowOpts): string { - const { id, label, value, monospace, multiline } = opts; + const { id, label, value, kind, monospace, multiline } = opts; const placeholder = multiline ? `•••• (${value.length} chars)` : '••••'; const valueClass = `field-row__value${monospace ? ' monospace' : ''}`; + const kindAttr = kind ? ` data-field-kind="${escapeHtml(kind)}"` : ''; return ` -
+
${escapeHtml(label)} ${escapeHtml(placeholder)} @@ -101,7 +106,13 @@ export function wireFieldHandlers(scope: HTMLElement): void { row.setAttribute('data-revealed', 'false'); btn.textContent = 'show'; } else { - valueEl.textContent = plaintext; + const isPassword = row.getAttribute('data-field-kind') === 'password'; + valueEl.textContent = ''; + if (isPassword) { + valueEl.appendChild(colorizePassword(plaintext)); + } else { + valueEl.textContent = plaintext; + } row.setAttribute('data-revealed', 'true'); btn.textContent = 'hide'; } @@ -150,6 +161,7 @@ export function renderSections(item: Item, idPrefix: string): string { id: `${idPrefix}-s${sIdx}-f${fIdx}`, label: field.label, value: field.value.value, + kind: field.value.kind, }); } }); diff --git a/extension/src/popup/components/generator-panel.ts b/extension/src/popup/components/generator-panel.ts index 526c23b..1b5604c 100644 --- a/extension/src/popup/components/generator-panel.ts +++ b/extension/src/popup/components/generator-panel.ts @@ -6,6 +6,7 @@ import { sendMessage } from '../../shared/state'; import type { GeneratorRequest, VaultSettings } from '../../shared/types'; +import { colorizePassword } from '../../shared/password-coloring'; interface UiKnobs { kind: 'random' | 'bip39'; @@ -138,7 +139,10 @@ export function openGeneratorPanel(opts: OpenPanelOpts): void { const d = resp.data as { password?: string; passphrase?: string }; currentPreview = d.password ?? d.passphrase ?? ''; const el = host.querySelector('.preview__value'); - if (el) el.textContent = currentPreview; + if (el) { + el.textContent = ''; + el.appendChild(colorizePassword(currentPreview)); + } updateValidation(); } }, 150); diff --git a/extension/src/popup/components/settings.ts b/extension/src/popup/components/settings.ts index 0942d83..2cea82d 100644 --- a/extension/src/popup/components/settings.ts +++ b/extension/src/popup/components/settings.ts @@ -3,6 +3,11 @@ import { sendMessage, navigate, escapeHtml } from '../../shared/state'; import type { DeviceSettings } from '../../shared/types'; import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs'; +import { + loadColorScheme, saveColorScheme, resetColorScheme, + DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, +} from '../../shared/color-scheme'; +import { colorizePassword } from '../../shared/password-coloring'; export async function renderSettings(app: HTMLElement): Promise { app.innerHTML = '
'; @@ -62,6 +67,9 @@ export async function renderSettings(app: HTMLElement): Promise {
+
+
+
blacklisted sites
@@ -119,4 +127,65 @@ export async function renderSettings(app: HTMLElement): Promise { } }); }); + + // Render Display section after the rest of the DOM is ready + await renderDisplaySection(); +} + +function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void { + swatch.style.setProperty('--relicario-pwd-digit-color', digitColor); + swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor); + swatch.innerHTML = ''; + swatch.appendChild(colorizePassword('Abc123!@#xyz')); +} + +async function renderDisplaySection(): Promise { + // The Display section container must be present in the DOM before we call this + const container = document.getElementById('display-section-container'); + if (!container) return; + + const scheme = await loadColorScheme(); + + container.innerHTML = ` +
display
+
+ +
+
+ +
+
+
+ +
+ `; + + const digitInput = document.getElementById('display-digit-color') as HTMLInputElement; + const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement; + const swatch = document.getElementById('display-swatch') as HTMLElement; + + // Render initial swatch + updateSwatch(swatch, scheme.digit_color, scheme.symbol_color); + + async function onColorChange(): Promise { + const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value }; + await saveColorScheme(newScheme); + updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color); + } + + digitInput.addEventListener('change', () => void onColorChange()); + symbolInput.addEventListener('change', () => void onColorChange()); + + document.getElementById('display-reset')?.addEventListener('click', async () => { + await resetColorScheme(); + digitInput.value = DEFAULT_DIGIT_COLOR; + symbolInput.value = DEFAULT_SYMBOL_COLOR; + updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR); + }); } diff --git a/extension/src/popup/components/types/__tests__/login.test.ts b/extension/src/popup/components/types/__tests__/login.test.ts index 7077847..4dc46a6 100644 --- a/extension/src/popup/components/types/__tests__/login.test.ts +++ b/extension/src/popup/components/types/__tests__/login.test.ts @@ -26,7 +26,7 @@ vi.mock('../../../../setup/setup-helpers', () => ({ entropyText: vi.fn(() => ''), })); -import { renderForm } from '../login'; +import { renderForm, applyGeneratedPassword } from '../login'; import { sendMessage } from '../../../../shared/state'; describe('login form smart inputs', () => { @@ -154,3 +154,37 @@ describe('Login save shape', () => { expect(addCall).toBeUndefined(); }); }); + +describe('regenerate handler dispatches input event', () => { + it('dispatches an InputEvent on the input after value is set', () => { + const input = document.createElement('input'); + input.type = 'password'; + document.body.appendChild(input); + + const dispatchSpy = vi.spyOn(input, 'dispatchEvent'); + + applyGeneratedPassword(input, 'sCMtTJkF%GN^mF#-N6D%'); + + expect(input.value).toBe('sCMtTJkF%GN^mF#-N6D%'); + expect(input.type).toBe('text'); + expect(dispatchSpy).toHaveBeenCalled(); + const evt = dispatchSpy.mock.calls.find(c => c[0] instanceof InputEvent)?.[0] as InputEvent; + expect(evt).toBeDefined(); + expect(evt.type).toBe('input'); + expect(evt.bubbles).toBe(true); + + document.body.removeChild(input); + }); + + it('bubbling listener fires when applyGeneratedPassword is called', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + let listenerFired = false; + input.addEventListener('input', () => { listenerFired = true; }); + applyGeneratedPassword(input, 'newpass'); + expect(listenerFired).toBe(true); + + document.body.removeChild(input); + }); +}); diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts index f21bef7..6c7665e 100644 --- a/extension/src/popup/components/types/login.ts +++ b/extension/src/popup/components/types/login.ts @@ -29,6 +29,15 @@ import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/to import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools'; import { scheduleRate } from '../../../setup/setup-helpers'; +/// Sets a generated password on an input, reveals it as plain text, then +/// dispatches a synthetic InputEvent so listeners (e.g. the strength meter) +/// re-evaluate the new value. +export function applyGeneratedPassword(input: HTMLInputElement, value: string): void { + input.value = value; + input.type = 'text'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); +} + /// Called by the dispatcher before each render. Stops any in-flight /// tickers / intervals / listeners the previous view may have attached. export function teardown(): void { @@ -75,7 +84,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise ${renderSignatureBlock({ accent: 'gold', children: sigInner })}
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''} - ${renderConcealedRow({ id: 'login-password', label: 'password', value: password })} + ${renderConcealedRow({ id: 'login-password', label: 'password', value: password, kind: 'password' })} ${url ? renderRow({ label: 'url', value: url, href: url }) : ''} ${hasTotp ? `
@@ -348,19 +357,21 @@ export function renderForm( ${sectionsHtml} -
-
- - +
+
+
+ + +
+
- -
- ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} - ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''} -
- - + ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} + ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''} +
+ + +
`; @@ -433,7 +444,7 @@ export function renderForm( context: 'fill-field', onPicked: (value) => { const pw = document.getElementById('f-password') as HTMLInputElement | null; - if (pw) { pw.value = value; pw.type = 'text'; } + if (pw) applyGeneratedPassword(pw, value); }, }); }); diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index fe85981..c08ea50 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -4,6 +4,7 @@ /// Navigation works by updating `currentState` and calling `render()`. import type { Request, Response } from '../shared/messages'; +import { lookupErrorCopy } from '../shared/error-copy'; import type { ItemId, ManifestEntry, Item } from '../shared/types'; import { registerHost } from '../shared/state'; import { renderUnlock } from './components/unlock'; @@ -18,6 +19,7 @@ import { renderFieldHistory } from './components/field-history'; import { teardown as teardownTrash } from './components/trash'; import { teardown as teardownDevices } from './components/devices'; import { teardown as teardownFieldHistory } from './components/field-history'; +import { applyColorScheme } from '../shared/color-scheme'; // --- Escape HTML to prevent XSS --- export function escapeHtml(str: string): string { @@ -144,19 +146,8 @@ export function humanizeError(err: string): string { if (/settings json:/i.test(err)) { return 'Settings are in an invalid format — try reloading the extension.'; } - if (/vault_locked/i.test(err)) { - return 'Vault is locked. Unlock and try again.'; - } - if (/origin_mismatch/i.test(err)) { - return 'This login belongs to a different site — refusing to leak credentials cross-origin.'; - } - if (/unauthorized_sender/i.test(err)) { - return 'This action is not allowed from here.'; - } - if (/tab_navigated|captured_tab_gone/i.test(err)) { - return 'The browser tab changed before the fill could complete — try again.'; - } - return err; + const copy = lookupErrorCopy(err); + return copy.body; } // --- Navigation --- @@ -225,6 +216,14 @@ function render(): void { // --- Init --- async function init(): Promise { + await applyColorScheme(); + + chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'sync' && 'password_display_scheme' in changes) { + void applyColorScheme(); + } + }); + // Snapshot the active tab at popup-open — the fill path uses this // tabId/url pair so the SW can verify the tab hasn't navigated before // forwarding credentials (audit M5 + TOCTOU close via expectedHost). diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index d41fcd9..5379e8e 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -38,6 +38,10 @@ /* Focus */ --focus-ring: 0 0 0 2px var(--gold-ring); + + /* Password coloring (P1) */ + --relicario-pwd-digit-color: #2563eb; + --relicario-pwd-symbol-color: #dc2626; } * { @@ -1554,3 +1558,18 @@ textarea { .logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; } .logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; } .tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; } + +/* Password character-class coloring */ +.pwd-digit { color: var(--relicario-pwd-digit-color); } +.pwd-symbol { color: var(--relicario-pwd-symbol-color); } +.pwd-letter { color: inherit; } + +.color-preview-swatch { + font-family: ui-monospace, monospace; + font-size: 1.1rem; + padding: 8px 12px; + border: 1px solid var(--border-mid); + border-radius: 4px; + margin-top: 8px; + background: var(--bg-input); +} diff --git a/extension/src/setup/__tests__/setup.test.ts b/extension/src/setup/__tests__/setup.test.ts new file mode 100644 index 0000000..c148f83 --- /dev/null +++ b/extension/src/setup/__tests__/setup.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { finishSetup } from '../setup'; + +describe('finishSetup', () => { + beforeEach(() => { + (global as any).chrome = { + tabs: { + create: vi.fn(() => Promise.resolve({ id: 999 })), + getCurrent: vi.fn(() => Promise.resolve({ id: 42 })), + remove: vi.fn(() => Promise.resolve()), + }, + runtime: { + getURL: vi.fn((p: string) => `chrome-extension://abc/${p}`), + }, + }; + }); + + it('opens vault.html in a new tab', async () => { + await finishSetup(); + expect(chrome.runtime.getURL).toHaveBeenCalledWith('vault.html'); + expect(chrome.tabs.create).toHaveBeenCalledWith({ + url: 'chrome-extension://abc/vault.html', + }); + }); + + it('closes the current setup tab after opening the vault tab', async () => { + await finishSetup(); + expect(chrome.tabs.getCurrent).toHaveBeenCalled(); + expect(chrome.tabs.remove).toHaveBeenCalledWith(42); + }); + + it('still opens the vault tab even if closing the setup tab fails', async () => { + (chrome.tabs.remove as any).mockRejectedValueOnce(new Error('no permission')); + await expect(finishSetup()).resolves.not.toThrow(); + expect(chrome.tabs.create).toHaveBeenCalled(); + }); +}); diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index bd49f55..50895ee 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -1101,6 +1101,7 @@ function attachStep5(): void { state.configPushed = true; render(); + void finishSetup(); } catch (err: unknown) { console.error('[relicario setup] register device failed:', err); state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`; @@ -1131,6 +1132,23 @@ function attachStep5(): void { }); } +// --- Completion handoff --- + +/// Open the fullscreen vault tab and best-effort close the setup tab. +export async function finishSetup(): Promise { + const vaultUrl = chrome.runtime.getURL('vault.html'); + await chrome.tabs.create({ url: vaultUrl }); + try { + const current = await chrome.tabs.getCurrent(); + if (current?.id !== undefined) { + await chrome.tabs.remove(current.id); + } + } catch { + // Setup tab may not be closeable (e.g., opened as popup rather than a tab). + // The vault tab is open — that's the user-visible success. + } +} + // --- Boot --- document.addEventListener('DOMContentLoaded', () => { diff --git a/extension/src/shared/__tests__/color-scheme.test.ts b/extension/src/shared/__tests__/color-scheme.test.ts new file mode 100644 index 0000000..f1ab3b1 --- /dev/null +++ b/extension/src/shared/__tests__/color-scheme.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme, + DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, +} from '../color-scheme'; + +function mockChromeStorage(initial: any = {}) { + const store = { ...initial }; + (global as any).chrome = { + storage: { + sync: { + get: vi.fn((key: string) => Promise.resolve( + key in store ? { [key]: store[key] } : {})), + set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }), + remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }), + }, + }, + }; + return store; +} + +describe('color-scheme storage', () => { + beforeEach(() => { + // happy-dom provides document globally; reset inline styles between tests + document.documentElement.removeAttribute('style'); + }); + + it('load returns defaults when storage is empty', async () => { + mockChromeStorage(); + const scheme = await loadColorScheme(); + expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR); + expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR); + }); + + it('load returns stored values when present', async () => { + mockChromeStorage({ + password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' }, + }); + const scheme = await loadColorScheme(); + expect(scheme.digit_color).toBe('#123456'); + expect(scheme.symbol_color).toBe('#abcdef'); + }); + + it('save round-trips', async () => { + mockChromeStorage(); + await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' }); + const scheme = await loadColorScheme(); + expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' }); + }); + + it('reset removes the storage key', async () => { + const store = mockChromeStorage({ + password_display_scheme: { digit_color: '#000000', symbol_color: '#ffffff' }, + }); + await resetColorScheme(); + expect(store.password_display_scheme).toBeUndefined(); + const scheme = await loadColorScheme(); + expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR); + }); + + it('apply sets CSS custom properties on document.documentElement', async () => { + mockChromeStorage({ + password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' }, + }); + await applyColorScheme(); + const root = document.documentElement.style; + expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe'); + expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00'); + }); + + it('save rejects malformed hex values', async () => { + mockChromeStorage(); + await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' })) + .rejects.toThrow(); + }); +}); diff --git a/extension/src/shared/__tests__/error-copy.test.ts b/extension/src/shared/__tests__/error-copy.test.ts new file mode 100644 index 0000000..5e44b0d --- /dev/null +++ b/extension/src/shared/__tests__/error-copy.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { execSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { ERROR_COPY, lookupErrorCopy } from '../error-copy'; + +const repoRoot = resolve(__dirname, '../../../..'); + +function discoverCodes(): Set { + const out = execSync( + `grep -rohE "ok: false, error: '[^']+'" extension/src/service-worker/ \ + --include="*.ts" --exclude-dir=__tests__`, + { cwd: repoRoot, encoding: 'utf-8' }, + ); + const codes = new Set(); + for (const line of out.split('\n')) { + const m = line.match(/error: '([^']+)'/); + if (m) codes.add(m[1]); + } + return codes; +} + +describe('ERROR_COPY', () => { + it('contains an entry for every error code returned by the service worker', () => { + const discovered = discoverCodes(); + expect(discovered.size).toBeGreaterThan(0); + const missing: string[] = []; + for (const code of discovered) { + if (!ERROR_COPY[code]) missing.push(code); + } + expect(missing).toEqual([]); + }); + + it('lookupErrorCopy returns the mapped entry for known codes', () => { + const copy = lookupErrorCopy('vault_locked'); + expect(copy.title).toBe('Vault locked'); + expect(copy.body).toMatch(/unlock/i); + }); + + it('lookupErrorCopy falls back to a generic shape for unknown codes', () => { + const copy = lookupErrorCopy('made_up_code_xyz'); + expect(copy.title).toBe('Something went wrong'); + expect(copy.body).toContain('made_up_code_xyz'); + }); +}); diff --git a/extension/src/shared/__tests__/password-coloring.test.ts b/extension/src/shared/__tests__/password-coloring.test.ts new file mode 100644 index 0000000..b7421ce --- /dev/null +++ b/extension/src/shared/__tests__/password-coloring.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring'; + +describe('colorizePassword', () => { + + function classes(frag: DocumentFragment): string[] { + return Array.from(frag.querySelectorAll('span')).map(s => s.className); + } + function texts(frag: DocumentFragment): string[] { + return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? ''); + } + + it('returns empty fragment for empty input', () => { + const frag = colorizePassword(''); + expect(frag.childNodes.length).toBe(0); + }); + + it('classifies a mixed-class run', () => { + const frag = colorizePassword('aB3$xY'); + expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]); + expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']); + }); + + it('all-letters produces a single letter span', () => { + const frag = colorizePassword('passwd'); + expect(classes(frag)).toEqual([PWD_LETTER]); + expect(texts(frag)).toEqual(['passwd']); + }); + + it('all-digits produces a single digit span', () => { + const frag = colorizePassword('123456'); + expect(classes(frag)).toEqual([PWD_DIGIT]); + expect(texts(frag)).toEqual(['123456']); + }); + + it('all-symbols produces a single symbol span', () => { + const frag = colorizePassword('!@#$%^'); + expect(classes(frag)).toEqual([PWD_SYMBOL]); + expect(texts(frag)).toEqual(['!@#$%^']); + }); + + it('classifies unicode letters as letters', () => { + const frag = colorizePassword('áñü'); + expect(classes(frag)).toEqual([PWD_LETTER]); + }); + + it('classifies whitespace as symbol', () => { + const frag = colorizePassword('a b'); + expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]); + expect(texts(frag)).toEqual(['a', ' ', 'b']); + }); + + it('representative password snapshot: aB3$xY7&_!', () => { + const frag = colorizePassword('aB3$xY7&_!'); + expect(classes(frag)).toEqual([ + PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, + ]); + expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']); + }); +}); diff --git a/extension/src/shared/color-scheme.ts b/extension/src/shared/color-scheme.ts new file mode 100644 index 0000000..28abd74 --- /dev/null +++ b/extension/src/shared/color-scheme.ts @@ -0,0 +1,48 @@ +export const DEFAULT_DIGIT_COLOR = '#2563eb'; +export const DEFAULT_SYMBOL_COLOR = '#dc2626'; +const STORAGE_KEY = 'password_display_scheme'; +const HEX_RE = /^#[0-9a-fA-F]{6}$/; + +export interface ColorScheme { + digit_color: string; + symbol_color: string; +} + +export const DEFAULT_SCHEME: ColorScheme = { + digit_color: DEFAULT_DIGIT_COLOR, + symbol_color: DEFAULT_SYMBOL_COLOR, +}; + +function isValid(s: ColorScheme): boolean { + return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color); +} + +export async function loadColorScheme(): Promise { + const result = await chrome.storage.sync.get(STORAGE_KEY); + const stored = result[STORAGE_KEY] as Partial | undefined; + if (!stored) return { ...DEFAULT_SCHEME }; + return { + digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color) + ? stored.digit_color : DEFAULT_DIGIT_COLOR, + symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color) + ? stored.symbol_color : DEFAULT_SYMBOL_COLOR, + }; +} + +export async function saveColorScheme(scheme: ColorScheme): Promise { + if (!isValid(scheme)) { + throw new Error('Invalid color values; expected #rrggbb hex strings.'); + } + await chrome.storage.sync.set({ [STORAGE_KEY]: scheme }); +} + +export async function resetColorScheme(): Promise { + await chrome.storage.sync.remove(STORAGE_KEY); +} + +export async function applyColorScheme(): Promise { + const scheme = await loadColorScheme(); + const root = document.documentElement.style; + root.setProperty('--relicario-pwd-digit-color', scheme.digit_color); + root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color); +} diff --git a/extension/src/shared/error-copy.ts b/extension/src/shared/error-copy.ts new file mode 100644 index 0000000..71998c8 --- /dev/null +++ b/extension/src/shared/error-copy.ts @@ -0,0 +1,102 @@ +export interface ErrorCta { + label: string; + action?: 'unlock' | 'reload_extension' | 'open_setup'; +} + +export interface ErrorCopy { + title: string; + body: string; + cta?: ErrorCta; +} + +const UNLOCK_CTA: ErrorCta = { label: 'Unlock vault', action: 'unlock' }; + +export const ERROR_COPY: Record = { + vault_locked: { + title: 'Vault locked', + body: 'Unlock your vault to continue.', + cta: UNLOCK_CTA, + }, + unauthorized_sender: { + title: 'Action not allowed', + body: 'This action is not allowed from here.', + }, + unknown_message_type: { + title: 'Internal error', + body: 'The extension received an unknown request — try reloading.', + cta: { label: 'Reload extension', action: 'reload_extension' }, + }, + origin_mismatch: { + title: 'Wrong site', + body: 'This login belongs to a different site — refusing to leak credentials cross-origin.', + }, + not_a_login: { + title: 'Not a login', + body: 'That item does not have a username and password to fill.', + }, + no_totp: { + title: 'No 2FA on this item', + body: 'This item does not have a TOTP secret configured.', + }, + invalid_sender_url: { + title: 'Cannot read tab URL', + body: 'The current tab has no recognizable URL — try reloading the page.', + }, + tab_navigated: { + title: 'Tab changed', + body: 'The browser tab changed before the action could complete — try again.', + }, + captured_tab_gone: { + title: 'Tab is gone', + body: 'The browser tab closed before the action could complete — try again.', + }, + item_not_found: { + title: 'Item not found', + body: 'That item is no longer in the vault — it may have been deleted from another device.', + }, + attachment_not_found: { + title: 'Attachment missing', + body: 'The attachment is referenced in the item but is not present in the vault.', + }, + upload_failed: { + title: 'Upload failed', + body: 'Could not upload the attachment — check your connection and try again.', + }, + download_failed: { + title: 'Download failed', + body: 'Could not download the attachment — check your connection and try again.', + }, + 'invalid base32 secret': { + title: 'Invalid secret', + body: 'The TOTP secret must be valid Base32 (letters A-Z and digits 2-7 only).', + }, + 'no items to import': { + title: 'Nothing to import', + body: 'The CSV did not contain any importable items.', + }, + 'no reference image stored locally': { + title: 'No reference image', + body: 'This device has no reference image saved locally — re-attach the device or restore from backup.', + }, + 'remote already contains a Relicario vault': { + title: 'Vault already exists', + body: 'The selected repository already contains a vault — use Attach existing instead of Create new.', + }, + 'Extension not configured. Run setup first.': { + title: 'Extension not configured', + body: 'Run setup before using this action.', + cta: { label: 'Open setup', action: 'open_setup' }, + }, + 'Reference image not set. Run setup first.': { + title: 'Reference image missing', + body: 'Run setup to save your reference image.', + cta: { label: 'Open setup', action: 'open_setup' }, + }, +}; + +export function lookupErrorCopy(code: string): ErrorCopy { + return ERROR_COPY[code] ?? { + title: 'Something went wrong', + body: code, + }; +} diff --git a/extension/src/shared/password-coloring.ts b/extension/src/shared/password-coloring.ts new file mode 100644 index 0000000..222da85 --- /dev/null +++ b/extension/src/shared/password-coloring.ts @@ -0,0 +1,35 @@ +export const PWD_DIGIT = 'pwd-digit'; +export const PWD_SYMBOL = 'pwd-symbol'; +export const PWD_LETTER = 'pwd-letter'; + +type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER; + +function classify(ch: string): Class { + if (/^\d$/.test(ch)) return PWD_DIGIT; + if (/^\p{L}$/u.test(ch)) return PWD_LETTER; + return PWD_SYMBOL; +} + +export function colorizePassword(text: string): DocumentFragment { + const frag = document.createDocumentFragment(); + if (text.length === 0) return frag; + + const codepoints = Array.from(text); + let runStart = 0; + let runClass = classify(codepoints[0]); + + for (let i = 1; i <= codepoints.length; i++) { + const c = i < codepoints.length ? classify(codepoints[i]) : null; + if (c !== runClass) { + const span = document.createElement('span'); + span.className = runClass; + span.textContent = codepoints.slice(runStart, i).join(''); + frag.appendChild(span); + if (c !== null) { + runStart = i; + runClass = c; + } + } + } + return frag; +} diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 1cfb456..6555200 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -38,6 +38,10 @@ /* Focus */ --focus-ring: 0 0 0 2px var(--gold-ring); + + /* Password coloring (P1) */ + --relicario-pwd-digit-color: #2563eb; + --relicario-pwd-symbol-color: #dc2626; } * { @@ -144,6 +148,36 @@ body { margin-top: 8px; } +.error-block { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px 16px; + /* rgba channels derived from --danger (#ab2b20 = rgb(171, 43, 32)) */ + border: 1px solid rgba(171, 43, 32, 0.4); + border-radius: 6px; + background: rgba(171, 43, 32, 0.08); + margin-top: 12px; +} +.error-block .error-title { + font-weight: 600; + color: var(--danger); +} +.error-block .error-body { + color: var(--text); + font-size: 12px; + text-align: center; +} +.error-block .error-cta { + margin-top: 6px; +} + +/* Password character-class coloring */ +.pwd-digit { color: var(--relicario-pwd-digit-color); } +.pwd-symbol { color: var(--relicario-pwd-symbol-color); } +.pwd-letter { color: inherit; } + /* Buttons */ .btn { display: inline-block; @@ -1592,6 +1626,20 @@ textarea { @media (max-width: 720px) { .form-grid { grid-template-columns: 1fr; } } + +/* P3: lower form sections constrained to the same envelope as .form-grid. + Gated on surface === 'fullscreen' in login.ts; popup unaffected. */ +.form-lower { + max-width: 960px; + margin: 0 auto; +} +.form-lower > .form-group, +.form-lower > .disclosure, +.form-lower > .attachments-disclosure, +.form-lower > .form-actions { + width: 100%; +} + .form-col { padding: 14px 16px; } diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 4f0bb92..d30c2c1 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -9,6 +9,7 @@ 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_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs'; import { renderItemDetail } from '../popup/components/item-detail'; import { renderItemForm } from '../popup/components/item-form'; @@ -19,6 +20,7 @@ import { renderVaultSettings as renderVaultSettingsView } from '../popup/compone 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'; // --------------------------------------------------------------------------- // Helpers @@ -41,6 +43,21 @@ function escapeHtml(str: string): string { .replace(/'/g, '''); } +function renderErrorBlock(code: string | null | undefined): string { + if (!code) return ''; + const copy = lookupErrorCopy(code); + const ctaHtml = copy.cta + ? `` + : ''; + return ` +
+
${escapeHtml(copy.title)}
+
${escapeHtml(copy.body)}
+ ${ctaHtml} +
+ `; +} + function typeIcon(t: ItemType): string { switch (t) { case 'login': return '\u{1F511}'; // key @@ -199,7 +216,7 @@ function renderLockScreen(app: HTMLElement): void {
- ${state.error ? `
${escapeHtml(state.error)}
` : ''} + ${renderErrorBlock(state.error)}
`; @@ -592,6 +609,36 @@ async function loadManifest(): Promise { // --------------------------------------------------------------------------- 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('.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) { diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 89512ab..bcffb72 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -12,5 +12,5 @@ "baseUrl": "." }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**"] + "exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**", "src/__stubs__/**"] } diff --git a/extension/vitest.config.ts b/extension/vitest.config.ts index 02569a1..563f5f4 100644 --- a/extension/vitest.config.ts +++ b/extension/vitest.config.ts @@ -1,6 +1,14 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ + resolve: { + alias: { + // Stub the runtime-only WASM module so unit tests can import setup.ts. + '../relicario_wasm.js': path.resolve(__dirname, 'src/__stubs__/relicario_wasm.stub.ts'), + 'relicario-wasm': path.resolve(__dirname, 'src/__stubs__/relicario_wasm.stub.ts'), + }, + }, test: { environment: 'happy-dom', include: ['src/**/__tests__/**/*.test.ts'],