Compare commits
15 Commits
feature/v0
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9d834920 | ||
|
|
631e9af470 | ||
|
|
b2fc56709a | ||
|
|
b928ed407b | ||
|
|
6bca0b3526 | ||
|
|
f45c275566 | ||
|
|
3e4312ca6f | ||
|
|
518b41e9cd | ||
|
|
1de7cda1b0 | ||
|
|
25c9eb52a0 | ||
|
|
2df636e454 | ||
|
|
575343dc19 | ||
|
|
1c641b4911 | ||
|
|
214e1e49f8 | ||
|
|
648dcf386e |
13
extension/src/__stubs__/relicario_wasm.stub.ts
Normal file
13
extension/src/__stubs__/relicario_wasm.stub.ts
Normal file
@@ -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<void> {}
|
||||
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'); };
|
||||
@@ -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<string, unknown> = {}) {
|
||||
const store: Record<string, unknown> = { ...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<string, unknown>) => { 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<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
@@ -52,6 +70,7 @@ describe('settings view', () => {
|
||||
});
|
||||
|
||||
it('shows the error when sync fails', async () => {
|
||||
mockChromeStorage();
|
||||
settingsResponses();
|
||||
(sendMessage as ReturnType<typeof vi.fn>).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 = '<div id="app"></div>';
|
||||
app = document.getElementById('app')!;
|
||||
(sendMessage as ReturnType<typeof vi.fn>).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<HTMLInputElement>('#display-digit-color');
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#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<HTMLInputElement>('#display-digit-color');
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#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<HTMLInputElement>('#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<typeof vi.fn>;
|
||||
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<HTMLInputElement>('#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<typeof vi.fn>;
|
||||
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<HTMLButtonElement>('#display-reset')!;
|
||||
resetBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType<typeof vi.fn>;
|
||||
expect(syncRemove).toHaveBeenCalledWith('password_display_scheme');
|
||||
|
||||
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
|
||||
expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR);
|
||||
expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Colorize revealed entries: replace plain-text content with colorized spans
|
||||
app.querySelectorAll<HTMLElement>('.history-entry__value.revealed').forEach((el) => {
|
||||
const key = el.closest<HTMLElement>('.history-entry')?.dataset.entry ?? '';
|
||||
const plaintext = valueStore.get(key);
|
||||
if (plaintext !== undefined) {
|
||||
el.textContent = '';
|
||||
el.appendChild(colorizePassword(plaintext));
|
||||
}
|
||||
});
|
||||
|
||||
// Wire handlers
|
||||
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
||||
|
||||
|
||||
@@ -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 `
|
||||
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}">
|
||||
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}"${kindAttr}>
|
||||
<span class="field-row__label">${escapeHtml(label)}</span>
|
||||
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
||||
<span class="field-row__actions">
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
||||
@@ -62,6 +67,9 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;" id="display-section-container">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
||||
<div id="blacklist-container">
|
||||
@@ -119,4 +127,65 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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<void> {
|
||||
// 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 = `
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
|
||||
digit color
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
||||
symbol color
|
||||
</label>
|
||||
</div>
|
||||
<div id="display-swatch" class="color-preview-swatch"></div>
|
||||
<div style="margin-top:8px;">
|
||||
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>
|
||||
${renderSignatureBlock({ accent: 'gold', children: sigInner })}
|
||||
</div>
|
||||
${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 ? `
|
||||
<div class="field-row">
|
||||
@@ -348,19 +357,21 @@ export function renderForm(
|
||||
|
||||
${sectionsHtml}
|
||||
|
||||
<div class="form-group">
|
||||
<div class="notes-with-toggle">
|
||||
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
||||
<button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
|
||||
<div class="${surface === 'fullscreen' ? 'form-lower' : ''}">
|
||||
<div class="form-group">
|
||||
<div class="notes-with-toggle">
|
||||
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
||||
<button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
|
||||
</div>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
||||
</div>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
||||
</div>
|
||||
|
||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
||||
<div class="form-actions" ${externalActions ? 'hidden' : ''}>
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
||||
<div class="form-actions" ${externalActions ? 'hidden' : ''}>
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
37
extension/src/setup/__tests__/setup.test.ts
Normal file
37
extension/src/setup/__tests__/setup.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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', () => {
|
||||
|
||||
76
extension/src/shared/__tests__/color-scheme.test.ts
Normal file
76
extension/src/shared/__tests__/color-scheme.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
44
extension/src/shared/__tests__/error-copy.test.ts
Normal file
44
extension/src/shared/__tests__/error-copy.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
60
extension/src/shared/__tests__/password-coloring.test.ts
Normal file
60
extension/src/shared/__tests__/password-coloring.test.ts
Normal file
@@ -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', '&_!']);
|
||||
});
|
||||
});
|
||||
48
extension/src/shared/color-scheme.ts
Normal file
48
extension/src/shared/color-scheme.ts
Normal file
@@ -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<ColorScheme> {
|
||||
const result = await chrome.storage.sync.get(STORAGE_KEY);
|
||||
const stored = result[STORAGE_KEY] as Partial<ColorScheme> | 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<void> {
|
||||
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<void> {
|
||||
await chrome.storage.sync.remove(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function applyColorScheme(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
102
extension/src/shared/error-copy.ts
Normal file
102
extension/src/shared/error-copy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
35
extension/src/shared/password-coloring.ts
Normal file
35
extension/src/shared/password-coloring.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? `<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 +216,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 +609,36 @@ async function loadManifest(): Promise<void> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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) {
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**"]
|
||||
"exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**", "src/__stubs__/**"]
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user