5 Commits

Author SHA1 Message Date
adlee-was-taken
2df636e454 fix(ext/login): dispatch input event after regenerate sets password (B1)
Programmatic input.value = newPassword does not fire input events, so
the strength-meter listener at shared/form-affordances/password-tools.ts:65
never re-rates the new value — meter stays stuck on the prior reading.

Extract applyGeneratedPassword(input, value) helper that sets value, type,
then dispatches new InputEvent('input', { bubbles: true }). Vitest covers
the dispatch + a sanity check that bubbling listeners fire.
2026-05-02 16:46:06 -04:00
adlee-was-taken
575343dc19 refactor(ext/vault): event delegation for error-cta + CSS variable consistency 2026-05-02 16:41:39 -04:00
adlee-was-taken
1c641b4911 fix(ext/vault): friendly error block in fullscreen tab (closes B2)
Replaces raw escapeHtml(state.error) renders with lookupErrorCopy()-driven
title/body/CTA blocks. vault_locked specifically gets an 'Unlock vault'
CTA that refocuses the passphrase input. Other CTAs route to setup.html
or chrome.runtime.reload().

Closes B2; concludes P4.
2026-05-02 16:37:16 -04:00
adlee-was-taken
214e1e49f8 test(ext/shared): pin fallback title assertion in error-copy test 2026-05-02 16:30:09 -04:00
adlee-was-taken
648dcf386e feat(ext/shared): centralize error-message copy in ERROR_COPY map
Replaces the popup's regex-chain humanizeError with a total lookup over
every error code returned by extension/src/service-worker/router/. A
generated test discovers codes via grep so the registry can't drift.
The popup keeps its small set of regex translators for Rust/serde error
phrasing that doesn't go through the router's error vocabulary.

Subsumes B2 — fullscreen consumer lands in the next commit.
2026-05-02 16:26:01 -04:00
7 changed files with 258 additions and 16 deletions

View File

@@ -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);
});
});

View File

@@ -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 {
@@ -433,7 +442,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);
},
});
});

View File

@@ -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';
@@ -144,19 +145,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 ---

View File

@@ -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<string> {
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<string>();
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');
});
});

View File

@@ -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<string, ErrorCopy> = {
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,
};
}

View File

@@ -144,6 +144,31 @@ 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;
}
/* Buttons */
.btn {
display: inline-block;

View File

@@ -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';
@@ -41,6 +42,21 @@ function escapeHtml(str: string): string {
.replace(/'/g, '&#39;');
}
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 '\u{1F511}'; // key
@@ -199,7 +215,7 @@ function renderLockScreen(app: HTMLElement): void {
<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>` : ''}
${renderErrorBlock(state.error)}
</div>
</div>
`;
@@ -592,6 +608,28 @@ async function loadManifest(): Promise<void> {
// ---------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', async () => {
// 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) {