Merge feature/fullscreen-ux-phase-2a: smart-input affordances
Phase 2A of the fullscreen UX redesign — 8 form-level smart-input affordances (URL fill-from-tab + hostname chip, group autocomplete, password reveal + strength bar, TOTP live preview + QR decode, notes monospace toggle), shared between popup and fullscreen vault tabs via the new extension/src/shared/form-affordances/ module set. CLI parity: - relicario rate <passphrase> (zxcvbn score / guess estimate) - relicario completions <SHELL> (bash/zsh/fish via clap_complete) - --group <TAB> dynamic enumeration via .relicario/groups.cache (plaintext leak surface; opt out with RELICARIO_NO_GROUPS_CACHE=1) - --totp-qr <path> on add login + edit (rqrr decode) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
3528
extension/package-lock.json
generated
Normal file
3528
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsqr": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
"copy-webpack-plugin": "^12.0",
|
||||
|
||||
122
extension/src/popup/components/types/__tests__/login.test.ts
Normal file
122
extension/src/popup/components/types/__tests__/login.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../shared/state', async () => {
|
||||
const navigate = vi.fn();
|
||||
const setState = vi.fn();
|
||||
const sendMessage = vi.fn();
|
||||
const getState = vi.fn(() => ({
|
||||
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
|
||||
searchQuery: '', activeGroup: null, error: null, loading: false,
|
||||
capturedTabId: null, capturedUrl: '', newType: 'login',
|
||||
generatorDefaults: null,
|
||||
}));
|
||||
const escapeHtml = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return {
|
||||
navigate, setState, sendMessage, getState, escapeHtml,
|
||||
popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock setup-helpers (scheduleRate used by wirePasswordStrength)
|
||||
vi.mock('../../../../setup/setup-helpers', () => ({
|
||||
scheduleRate: vi.fn(),
|
||||
STRENGTH_LABELS: {},
|
||||
entropyText: vi.fn(() => ''),
|
||||
}));
|
||||
|
||||
import { renderForm } from '../login';
|
||||
import { sendMessage } from '../../../../shared/state';
|
||||
|
||||
describe('login form smart inputs', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
// chrome.storage.local stub (needed by wireNotesMonoToggle)
|
||||
(globalThis as any).chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})),
|
||||
set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
};
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { groups: [] } });
|
||||
});
|
||||
|
||||
it('renders all 6 smart-input slots in the form', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
expect(document.querySelector('#fill-from-tab-btn')).not.toBeNull();
|
||||
expect(document.querySelector('#hostname-chip-row')).not.toBeNull();
|
||||
expect(document.querySelector('#reveal-password-btn')).not.toBeNull();
|
||||
expect(document.querySelector('#strength-bar-row')).not.toBeNull();
|
||||
expect(document.querySelector('#totp-preview-row')).not.toBeNull();
|
||||
expect(document.querySelector('#totp-qr-btn')).not.toBeNull();
|
||||
expect(document.querySelector('#totp-qr-panel')).not.toBeNull();
|
||||
expect(document.querySelector('#notes-mono-btn')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
(globalThis as any).chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})),
|
||||
set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()),
|
||||
},
|
||||
},
|
||||
runtime: { sendMessage: vi.fn() },
|
||||
};
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockImplementation(async (msg: any) => {
|
||||
if (msg.type === 'list_groups') return { ok: true, data: { groups: [] } };
|
||||
if (msg.type === 'preview_totp_from_secret') return { ok: false };
|
||||
return { ok: true, data: { id: 'fakeid0000000000', items: [] } };
|
||||
});
|
||||
});
|
||||
|
||||
it('saves a login item with url, username, and password', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'GitHub';
|
||||
(document.getElementById('f-url') as HTMLInputElement).value = 'https://github.com/login';
|
||||
(document.getElementById('f-username') as HTMLInputElement).value = 'alice';
|
||||
(document.getElementById('f-password') as HTMLInputElement).value = 'hunter2';
|
||||
|
||||
document.getElementById('save-btn')!.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
const calls = vi.mocked(sendMessage).mock.calls;
|
||||
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
|
||||
expect(addCall).toBeDefined();
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.type).toBe('login');
|
||||
expect(msg.item.core).toMatchObject({
|
||||
type: 'login',
|
||||
username: 'alice',
|
||||
password: 'hunter2',
|
||||
url: 'https://github.com/login',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects save when title is empty', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
document.getElementById('save-btn')!.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
const calls = vi.mocked(sendMessage).mock.calls;
|
||||
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
|
||||
expect(addCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -22,10 +22,20 @@ import {
|
||||
wireAttachmentsDisclosure,
|
||||
teardownAttachmentsDisclosure,
|
||||
} from '../attachments-disclosure';
|
||||
import { wireFillFromTab, wireHostnameChip } from '../../../shared/form-affordances/url-tools';
|
||||
import { wireGroupAutocomplete } from '../../../shared/form-affordances/group-autocomplete';
|
||||
import { wirePasswordReveal, wirePasswordStrength } from '../../../shared/form-affordances/password-tools';
|
||||
import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/totp-tools';
|
||||
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
|
||||
import { scheduleRate } from '../../../setup/setup-helpers';
|
||||
|
||||
/// Called by the dispatcher before each render. Stops any in-flight
|
||||
/// tickers / intervals / listeners the previous view may have attached.
|
||||
export function teardown(): void {
|
||||
for (const fn of pendingAffordanceTeardowns) {
|
||||
try { fn(); } catch { /* best effort */ }
|
||||
}
|
||||
pendingAffordanceTeardowns = [];
|
||||
teardownAttachmentsDisclosure();
|
||||
stopTotpTicker();
|
||||
if (activeKeyHandler) {
|
||||
@@ -202,6 +212,7 @@ let totpTickerId: ReturnType<typeof setInterval> | null = null;
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let sectionsExpanded = false;
|
||||
let pendingAffordanceTeardowns: Array<() => void> = [];
|
||||
function stopTotpTicker(): void {
|
||||
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
|
||||
}
|
||||
@@ -247,23 +258,63 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
<div class="pad">
|
||||
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
|
||||
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
|
||||
<div class="form-group"><label class="label" for="f-url">url</label>
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
||||
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
||||
</div>
|
||||
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group"><label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>
|
||||
<div class="form-group"><label class="label" for="f-password">password</label>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">✨</button>
|
||||
</div></div>
|
||||
<div class="form-group"><label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP"></div>
|
||||
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
||||
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
|
||||
</div>
|
||||
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
||||
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="strength-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
|
||||
</div>
|
||||
<div id="totp-preview-row" class="totp-preview" hidden>
|
||||
<span class="totp-code">…</span>
|
||||
<span class="totp-countdown">…</span>
|
||||
</div>
|
||||
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
||||
<input id="totp-qr-file" type="file" accept="image/*" />
|
||||
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
|
||||
<div id="totp-qr-error" class="totp-qr-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group"><label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
|
||||
<div class="form-group"><label class="label" for="f-notes">notes</label>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea></div>
|
||||
|
||||
<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>
|
||||
|
||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
||||
<div class="form-actions">
|
||||
@@ -305,6 +356,26 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
wireDisclosure();
|
||||
}
|
||||
|
||||
// ---- Smart input affordances ------------------------------------------
|
||||
// Each wireXxx call attaches event listeners to the just-rendered form.
|
||||
// Affordances that hold timers/intervals return a teardown fn we collect
|
||||
// here and run from the form's existing teardown() entry point.
|
||||
const affordanceTeardowns: Array<() => void> = [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sm = sendMessage as any;
|
||||
wireFillFromTab(app, { sendMessage: sm });
|
||||
wireHostnameChip(app);
|
||||
void wireGroupAutocomplete(app, { sendMessage: sm });
|
||||
affordanceTeardowns.push(wirePasswordReveal(app));
|
||||
wirePasswordStrength(app, { scheduleRate });
|
||||
affordanceTeardowns.push(wireTotpPreview(app, { sendMessage: sm }));
|
||||
wireTotpQr(app);
|
||||
void wireNotesMonoToggle(app, { itemId: existing?.id ?? '' });
|
||||
|
||||
// Stash teardown-runner so the existing `teardown()` calls it.
|
||||
pendingAffordanceTeardowns = affordanceTeardowns;
|
||||
|
||||
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
|
||||
const trigger = e.currentTarget as HTMLElement;
|
||||
if (isGeneratorPanelOpen()) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
--bg-page: #0d1117;
|
||||
--bg-pane: #161b22;
|
||||
--bg-elevated: #21262d;
|
||||
--bg-input: #161b22;
|
||||
--border-subtle: #30363d;
|
||||
|
||||
/* Text */
|
||||
@@ -1332,3 +1333,127 @@ textarea {
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Glyph button used by smart-input affordances. Sits inline with an input. */
|
||||
.glyph-btn {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.glyph-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.glyph-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.glyph-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hostname-chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.hostname-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #0c1118;
|
||||
}
|
||||
.hostname-text {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.strength-bar-row {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 4px;
|
||||
}
|
||||
.strength-bar > span {
|
||||
flex: 1;
|
||||
background: var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.strength-bar.s-very-weak > span.lit { background: #c75a4f; }
|
||||
.strength-bar.s-weak > span.lit { background: #c75a4f; }
|
||||
.strength-bar.s-fair > span.lit { background: #d49b3a; }
|
||||
.strength-bar.s-good > span.lit { background: #d49b3a; }
|
||||
.strength-bar.s-strong > span.lit { background: #6cb37a; }
|
||||
.strength-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.totp-preview {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-code {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.totp-countdown {
|
||||
font-size: 11px;
|
||||
}
|
||||
.totp-qr-panel {
|
||||
margin-top: 6px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.totp-qr-panel input[type="file"] {
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-qr-error {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--danger, #c75a4f);
|
||||
}
|
||||
.notes-with-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.f-notes--mono {
|
||||
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// --- Mocks (must be declared before `route` is imported so the router's
|
||||
// `import * as vault` / `import * as session` resolve to these doubles) ---
|
||||
@@ -921,3 +921,120 @@ describe('parse_lastpass_csv / import_lastpass_commit sender check', () => {
|
||||
expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- get_active_tab_url ---
|
||||
|
||||
describe('get_active_tab_url', () => {
|
||||
let originalChrome: any;
|
||||
beforeEach(() => { originalChrome = (globalThis as any).chrome; });
|
||||
afterEach(() => { (globalThis as any).chrome = originalChrome; });
|
||||
|
||||
it('get_active_tab_url returns active tab url + title', async () => {
|
||||
// happy-dom does not provide chrome.tabs; stub it.
|
||||
(globalThis as any).chrome = {
|
||||
...((globalThis as any).chrome ?? {}),
|
||||
tabs: {
|
||||
query: (q: any, cb: (tabs: any[]) => void) => {
|
||||
cb([{ url: 'https://github.com/login', title: 'Sign in to GitHub' }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ url: 'https://github.com/login', title: 'Sign in to GitHub' });
|
||||
});
|
||||
|
||||
it('get_active_tab_url returns null for chrome:// pages', async () => {
|
||||
(globalThis as any).chrome = {
|
||||
...((globalThis as any).chrome ?? {}),
|
||||
tabs: {
|
||||
query: (q: any, cb: (tabs: any[]) => void) => {
|
||||
cb([{ url: 'chrome://newtab/', title: 'New Tab' }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toBeNull();
|
||||
});
|
||||
|
||||
it('get_active_tab_url returns null for view-source: URLs', async () => {
|
||||
(globalThis as any).chrome = {
|
||||
...((globalThis as any).chrome ?? {}),
|
||||
tabs: {
|
||||
query: (q: any, cb: (tabs: any[]) => void) => {
|
||||
cb([{ url: 'view-source:https://github.com/login', title: 'View Source' }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- list_groups ---
|
||||
|
||||
describe('list_groups', () => {
|
||||
it('list_groups returns deduplicated sorted groups from manifest', async () => {
|
||||
const state = makeState();
|
||||
state.manifest = {
|
||||
schema_version: 2,
|
||||
items: {
|
||||
a: { id: 'a', title: 't1', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
|
||||
b: { id: 'b', title: 't2', type: 'login', group: 'personal', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
|
||||
c: { id: 'c', title: 't3', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
|
||||
d: { id: 'd', title: 't4', type: 'login', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] }, // no group
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'list_groups' } as any, state, makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ groups: ['personal', 'work'] });
|
||||
});
|
||||
|
||||
it('list_groups returns empty array when manifest is null', async () => {
|
||||
const state = makeState();
|
||||
state.manifest = null;
|
||||
const resp = await route({ type: 'list_groups' } as any, state, makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ groups: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// --- preview_totp_from_secret ---
|
||||
|
||||
describe('preview_totp_from_secret', () => {
|
||||
let originalChrome: any;
|
||||
beforeEach(() => { originalChrome = (globalThis as any).chrome; });
|
||||
afterEach(() => { (globalThis as any).chrome = originalChrome; });
|
||||
|
||||
it('returns code for valid base32', async () => {
|
||||
const state = makeState();
|
||||
state.wasm = {
|
||||
totp_compute: vi.fn().mockReturnValue({ code: '123456', expires_at: 9_999_999_999 }),
|
||||
};
|
||||
const resp = await route(
|
||||
{ type: 'preview_totp_from_secret', secret_b32: 'JBSWY3DPEHPK3PXP' } as any,
|
||||
state, makePopupSender(),
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ code: '123456', expires_at: 9_999_999_999 });
|
||||
// Verify a transient TotpConfig was passed (sha1, 6 digits, 30s)
|
||||
const cfgArg = JSON.parse(state.wasm.totp_compute.mock.calls[0][0]);
|
||||
expect(cfgArg.algorithm).toBe('sha1');
|
||||
expect(cfgArg.digits).toBe(6);
|
||||
expect(cfgArg.period_seconds).toBe(30);
|
||||
});
|
||||
|
||||
it('rejects invalid base32', async () => {
|
||||
const state = makeState();
|
||||
state.wasm = { totp_compute: vi.fn() };
|
||||
const resp = await route(
|
||||
{ type: 'preview_totp_from_secret', secret_b32: 'too-short!!!' } as any,
|
||||
state, makePopupSender(),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(resp.error).toMatch(/invalid/i);
|
||||
expect(state.wasm.totp_compute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import { base32Decode } from '../../shared/base32';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
@@ -146,6 +147,52 @@ export async function handle(
|
||||
case 'rate_passphrase':
|
||||
return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };
|
||||
|
||||
case 'get_active_tab_url': {
|
||||
const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {
|
||||
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (t) => resolve(t));
|
||||
});
|
||||
const tab = tabs[0];
|
||||
if (!tab?.url) return { ok: true, data: null };
|
||||
// Filter out chrome:// and extension URLs — autofill doesn't apply.
|
||||
if (/^(chrome|chrome-extension|chrome-search|moz-extension|edge|edge-extension|about|file|view-source|data|devtools|javascript):/i.test(tab.url)) {
|
||||
return { ok: true, data: null };
|
||||
}
|
||||
return { ok: true, data: { url: tab.url, title: tab.title ?? '' } };
|
||||
}
|
||||
|
||||
case 'list_groups': {
|
||||
if (!state.manifest) return { ok: true, data: { groups: [] } };
|
||||
const set = new Set<string>();
|
||||
for (const id in state.manifest.items) {
|
||||
const g = state.manifest.items[id].group;
|
||||
if (g) set.add(g);
|
||||
}
|
||||
return { ok: true, data: { groups: Array.from(set).sort() } };
|
||||
}
|
||||
|
||||
case 'preview_totp_from_secret': {
|
||||
const cleaned = msg.secret_b32.toUpperCase().replace(/\s+/g, '').replace(/=+$/, '');
|
||||
if (cleaned.length < 16 || !/^[A-Z2-7]+$/.test(cleaned)) {
|
||||
return { ok: false, error: 'invalid base32 secret' };
|
||||
}
|
||||
let secretBytes: Uint8Array;
|
||||
try {
|
||||
secretBytes = base32Decode(cleaned);
|
||||
} catch (e) {
|
||||
return { ok: false, error: `invalid base32: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
const cfg = {
|
||||
secret: Array.from(secretBytes),
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: 'totp',
|
||||
};
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = state.wasm.totp_compute(JSON.stringify(cfg), BigInt(now));
|
||||
return { ok: true, data: { code: result.code, expires_at: result.expires_at } };
|
||||
}
|
||||
|
||||
case 'generate_password': {
|
||||
const password = state.wasm.generate_password(JSON.stringify(msg.request));
|
||||
return { ok: true, data: { password } };
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { wireGroupAutocomplete } from '../group-autocomplete';
|
||||
|
||||
describe('wireGroupAutocomplete', () => {
|
||||
let form: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up any datalist from a prior test
|
||||
document.getElementById('groups-datalist')?.remove();
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `<input id="f-group" type="text" />`;
|
||||
document.body.appendChild(form);
|
||||
});
|
||||
|
||||
it('attaches datalist with all groups', async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
data: { groups: ['personal', 'work', 'finance'] },
|
||||
});
|
||||
await wireGroupAutocomplete(form, { sendMessage });
|
||||
const list = document.getElementById('groups-datalist') as HTMLDataListElement | null;
|
||||
expect(list).not.toBeNull();
|
||||
const opts = Array.from(list!.querySelectorAll('option')).map((o) => o.value);
|
||||
expect(opts).toEqual(['personal', 'work', 'finance']);
|
||||
const input = form.querySelector('#f-group') as HTMLInputElement;
|
||||
expect(input.getAttribute('list')).toBe('groups-datalist');
|
||||
});
|
||||
|
||||
it('is a no-op if SW returns error', async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue({ ok: false, error: 'vault_locked' });
|
||||
await wireGroupAutocomplete(form, { sendMessage });
|
||||
const input = form.querySelector('#f-group') as HTMLInputElement;
|
||||
expect(input.getAttribute('list')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as affordances from '../index';
|
||||
|
||||
describe('form-affordances barrel', () => {
|
||||
it('exports nothing yet but the module loads', () => {
|
||||
expect(typeof affordances).toBe('object');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { wireNotesMonoToggle } from '../notes-tools';
|
||||
|
||||
describe('wireNotesMonoToggle', () => {
|
||||
let form: HTMLElement;
|
||||
let storage: { get: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<button id="notes-mono-btn" class="glyph-btn" type="button" title="monospace">≡</button>
|
||||
<textarea id="f-notes"></textarea>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
storage = {
|
||||
get: vi.fn().mockImplementation((_keys, cb) => cb({})),
|
||||
set: vi.fn().mockImplementation((_obj, cb) => cb && cb()),
|
||||
};
|
||||
(globalThis as any).chrome = { storage: { local: storage } };
|
||||
});
|
||||
|
||||
it('toggles class on click and persists', async () => {
|
||||
await wireNotesMonoToggle(form, { itemId: 'abc123' });
|
||||
const btn = form.querySelector('#notes-mono-btn') as HTMLButtonElement;
|
||||
const ta = form.querySelector('#f-notes') as HTMLTextAreaElement;
|
||||
expect(ta.classList.contains('f-notes--mono')).toBe(false);
|
||||
btn.click();
|
||||
expect(ta.classList.contains('f-notes--mono')).toBe(true);
|
||||
expect(storage.set).toHaveBeenCalledWith({ 'notesMono.abc123': true }, expect.any(Function));
|
||||
});
|
||||
|
||||
it('restores prior state on mount', async () => {
|
||||
storage.get.mockImplementation((_keys, cb) => cb({ 'notesMono.abc123': true }));
|
||||
await wireNotesMonoToggle(form, { itemId: 'abc123' });
|
||||
const ta = form.querySelector('#f-notes') as HTMLTextAreaElement;
|
||||
expect(ta.classList.contains('f-notes--mono')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { wirePasswordReveal, wirePasswordStrength } from '../password-tools';
|
||||
|
||||
describe('wirePasswordReveal', () => {
|
||||
let form: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-password" type="password" value="secret" />
|
||||
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(form);
|
||||
});
|
||||
|
||||
it('flips input.type and glyph on click', () => {
|
||||
wirePasswordReveal(form);
|
||||
const btn = form.querySelector('#reveal-password-btn') as HTMLButtonElement;
|
||||
const input = form.querySelector('#f-password') as HTMLInputElement;
|
||||
expect(input.type).toBe('password');
|
||||
expect(btn.textContent).toBe('⊙');
|
||||
|
||||
btn.click();
|
||||
expect(input.type).toBe('text');
|
||||
expect(btn.textContent).toBe('⊘');
|
||||
expect(btn.title).toBe('hide');
|
||||
|
||||
btn.click();
|
||||
expect(input.type).toBe('password');
|
||||
expect(btn.textContent).toBe('⊙');
|
||||
expect(btn.title).toBe('reveal');
|
||||
});
|
||||
|
||||
it('teardown returned by wirePasswordReveal resets to password', () => {
|
||||
const teardown = wirePasswordReveal(form);
|
||||
const btn = form.querySelector('#reveal-password-btn') as HTMLButtonElement;
|
||||
const input = form.querySelector('#f-password') as HTMLInputElement;
|
||||
btn.click(); // now revealed
|
||||
expect(input.type).toBe('text');
|
||||
teardown();
|
||||
expect(input.type).toBe('password');
|
||||
expect(btn.textContent).toBe('⊙');
|
||||
expect(btn.title).toBe('reveal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wirePasswordStrength', () => {
|
||||
let form: HTMLElement;
|
||||
let scheduleRate: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-password" type="password" value="" />
|
||||
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
||||
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="strength-label"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
scheduleRate = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(form);
|
||||
});
|
||||
|
||||
it('shows bar with score class on input', () => {
|
||||
scheduleRate.mockImplementation((_pw, cb) => cb({ score: 3, guessesLog10: 11.4 }));
|
||||
wirePasswordStrength(form, { scheduleRate });
|
||||
const input = form.querySelector('#f-password') as HTMLInputElement;
|
||||
input.value = 'CorrectHorseBatteryStaple';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
const row = form.querySelector('#strength-bar-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(false);
|
||||
expect(row.querySelector('.strength-bar')?.className).toContain('s-good');
|
||||
expect(row.querySelector('.strength-label')?.textContent).toContain('good');
|
||||
expect(row.querySelector('.strength-label')?.textContent).toContain('10^11');
|
||||
});
|
||||
|
||||
it('hides bar when input is empty', () => {
|
||||
scheduleRate.mockImplementation((_pw, cb) => cb({ score: -1, guessesLog10: -1 }));
|
||||
wirePasswordStrength(form, { scheduleRate });
|
||||
const input = form.querySelector('#f-password') as HTMLInputElement;
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
const row = form.querySelector('#strength-bar-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { wireTotpPreview, wireTotpQr } from '../totp-tools';
|
||||
|
||||
describe('wireTotpPreview', () => {
|
||||
let form: HTMLElement;
|
||||
let sendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-totp" type="text" value="" />
|
||||
<div id="totp-preview-row" class="totp-preview" hidden>
|
||||
<span class="totp-code">…</span>
|
||||
<span class="totp-countdown">…</span>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
sendMessage = vi.fn();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('shows preview when secret is valid base32', async () => {
|
||||
sendMessage.mockResolvedValue({ ok: true, data: { code: '492837', expires_at: Math.floor(Date.now() / 1000) + 23 } });
|
||||
const teardown = wireTotpPreview(form, { sendMessage });
|
||||
const input = form.querySelector('#f-totp') as HTMLInputElement;
|
||||
input.value = 'JBSWY3DPEHPK3PXP';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const row = form.querySelector('#totp-preview-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(false);
|
||||
expect(row.querySelector('.totp-code')?.textContent).toBe('492 837');
|
||||
expect(row.querySelector('.totp-countdown')?.textContent).toMatch(/\d+s/);
|
||||
teardown();
|
||||
});
|
||||
|
||||
it('hides preview when secret is too short', async () => {
|
||||
const teardown = wireTotpPreview(form, { sendMessage });
|
||||
const input = form.querySelector('#f-totp') as HTMLInputElement;
|
||||
input.value = 'TOOSHORT';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const row = form.querySelector('#totp-preview-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(true);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
teardown();
|
||||
});
|
||||
|
||||
it('teardown stops the interval', async () => {
|
||||
sendMessage.mockResolvedValue({ ok: true, data: { code: '111111', expires_at: Math.floor(Date.now() / 1000) + 30 } });
|
||||
const teardown = wireTotpPreview(form, { sendMessage });
|
||||
const input = form.querySelector('#f-totp') as HTMLInputElement;
|
||||
input.value = 'JBSWY3DPEHPK3PXP';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const callsBefore = sendMessage.mock.calls.length;
|
||||
teardown();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(sendMessage.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireTotpQr', () => {
|
||||
let form: HTMLElement;
|
||||
let decodeQrFromBlob: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-totp" type="text" value="" />
|
||||
<button id="totp-qr-btn" class="glyph-btn" type="button" title="QR">◫</button>
|
||||
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
||||
<input id="totp-qr-file" type="file" accept="image/*" />
|
||||
<div id="totp-qr-error" class="totp-qr-error"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
decodeQrFromBlob = vi.fn();
|
||||
});
|
||||
|
||||
it('toggles the panel on button click', () => {
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const btn = form.querySelector('#totp-qr-btn') as HTMLButtonElement;
|
||||
const panel = form.querySelector('#totp-qr-panel') as HTMLElement;
|
||||
expect(panel.hidden).toBe(true);
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(false);
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('fills f-totp on successful decode of otpauth:// URI', async () => {
|
||||
decodeQrFromBlob.mockResolvedValue('otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example');
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
|
||||
const fakeFile = new File(['x'], 'qr.png', { type: 'image/png' });
|
||||
Object.defineProperty(fileInput, 'files', { value: [fakeFile] });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe('JBSWY3DPEHPK3PXP');
|
||||
});
|
||||
|
||||
it('shows error when QR decodes but is not otpauth://', async () => {
|
||||
decodeQrFromBlob.mockResolvedValue('https://example.com/');
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
|
||||
Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
const err = form.querySelector('#totp-qr-error') as HTMLElement;
|
||||
expect(err.textContent).toMatch(/not a totp uri/i);
|
||||
expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('shows error when decode returns null (no QR found)', async () => {
|
||||
decodeQrFromBlob.mockResolvedValue(null);
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
|
||||
Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
const err = form.querySelector('#totp-qr-error') as HTMLElement;
|
||||
expect(err.textContent).toMatch(/no qr found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { wireFillFromTab, wireHostnameChip } from '../url-tools';
|
||||
|
||||
describe('wireFillFromTab', () => {
|
||||
let form: HTMLElement;
|
||||
let sendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-title" type="text" />
|
||||
<div class="inline-row">
|
||||
<input id="f-url" type="text" />
|
||||
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
sendMessage = vi.fn();
|
||||
});
|
||||
|
||||
it('fills url + title from active tab on click', async () => {
|
||||
sendMessage.mockResolvedValue({ ok: true, data: { url: 'https://github.com/login', title: 'GitHub' } });
|
||||
wireFillFromTab(form, { sendMessage });
|
||||
(form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).click();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
expect((form.querySelector('#f-url') as HTMLInputElement).value).toBe('https://github.com/login');
|
||||
expect((form.querySelector('#f-title') as HTMLInputElement).value).toBe('GitHub');
|
||||
});
|
||||
|
||||
it('does not overwrite a non-empty title', async () => {
|
||||
(form.querySelector('#f-title') as HTMLInputElement).value = 'My GitHub';
|
||||
sendMessage.mockResolvedValue({ ok: true, data: { url: 'https://github.com/login', title: 'GitHub' } });
|
||||
wireFillFromTab(form, { sendMessage });
|
||||
(form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).click();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
expect((form.querySelector('#f-title') as HTMLInputElement).value).toBe('My GitHub');
|
||||
});
|
||||
|
||||
it('disables the button if SW returns null', async () => {
|
||||
sendMessage.mockResolvedValue({ ok: true, data: null });
|
||||
wireFillFromTab(form, { sendMessage });
|
||||
(form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).click();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
expect((form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireHostnameChip', () => {
|
||||
let form: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<div class="form-group">
|
||||
<input id="f-url" type="text" />
|
||||
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('renders chip + hostname on valid URL after debounce', () => {
|
||||
wireHostnameChip(form);
|
||||
const input = form.querySelector('#f-url') as HTMLInputElement;
|
||||
input.value = 'https://github.com/login';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
vi.advanceTimersByTime(250);
|
||||
const row = form.querySelector('#hostname-chip-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(false);
|
||||
expect(row.textContent).toContain('github.com');
|
||||
expect(row.querySelector('.hostname-chip')?.textContent).toBe('G');
|
||||
});
|
||||
|
||||
it('hides chip if URL is empty', () => {
|
||||
wireHostnameChip(form);
|
||||
const input = form.querySelector('#f-url') as HTMLInputElement;
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
vi.advanceTimersByTime(250);
|
||||
expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('hides chip if URL does not parse', () => {
|
||||
wireHostnameChip(form);
|
||||
const input = form.querySelector('#f-url') as HTMLInputElement;
|
||||
input.value = '!!!not-a-url';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
vi.advanceTimersByTime(250);
|
||||
expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('treats scheme-less host as https://', () => {
|
||||
wireHostnameChip(form);
|
||||
const input = form.querySelector('#f-url') as HTMLInputElement;
|
||||
input.value = 'gitlab.com/users/sign_in';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
vi.advanceTimersByTime(250);
|
||||
const row = form.querySelector('#hostname-chip-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(false);
|
||||
expect(row.textContent).toContain('gitlab.com');
|
||||
});
|
||||
});
|
||||
28
extension/src/shared/form-affordances/group-autocomplete.ts
Normal file
28
extension/src/shared/form-affordances/group-autocomplete.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface GroupAutocompleteOpts {
|
||||
sendMessage: (msg: { type: 'list_groups' }) => Promise<{ ok: boolean; data?: { groups: string[] }; error?: string }>;
|
||||
}
|
||||
|
||||
const DATALIST_ID = 'groups-datalist';
|
||||
|
||||
export async function wireGroupAutocomplete(form: HTMLElement, opts: GroupAutocompleteOpts): Promise<void> {
|
||||
const input = form.querySelector<HTMLInputElement>('#f-group');
|
||||
if (!input) return;
|
||||
let resp: Awaited<ReturnType<typeof opts.sendMessage>>;
|
||||
try {
|
||||
resp = await opts.sendMessage({ type: 'list_groups' });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!resp?.ok || !resp.data?.groups) return;
|
||||
|
||||
// Datalists must live in the document, not nested inside an input. Reuse if
|
||||
// we've already mounted one this session.
|
||||
let list = document.getElementById(DATALIST_ID) as HTMLDataListElement | null;
|
||||
if (!list) {
|
||||
list = document.createElement('datalist');
|
||||
list.id = DATALIST_ID;
|
||||
document.body.appendChild(list);
|
||||
}
|
||||
list.innerHTML = resp.data.groups.map((g) => `<option value="${g.replace(/"/g, '"')}"></option>`).join('');
|
||||
input.setAttribute('list', DATALIST_ID);
|
||||
}
|
||||
5
extension/src/shared/form-affordances/index.ts
Normal file
5
extension/src/shared/form-affordances/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/// Shared form affordance modules. Each named export wires one family of
|
||||
/// smart-input behavior (url, group, password, totp, notes) into a mounted
|
||||
/// form element. Wired by `popup/components/types/login.ts` after the form
|
||||
/// HTML is rendered.
|
||||
export {};
|
||||
29
extension/src/shared/form-affordances/notes-tools.ts
Normal file
29
extension/src/shared/form-affordances/notes-tools.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface NotesMonoOpts {
|
||||
/// Item ID for persistence — pass empty string for "add new" forms (state
|
||||
/// is then session-scoped under the key 'notesMono.__new__').
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export async function wireNotesMonoToggle(form: HTMLElement, opts: NotesMonoOpts): Promise<void> {
|
||||
const btn = form.querySelector<HTMLButtonElement>('#notes-mono-btn');
|
||||
const ta = form.querySelector<HTMLTextAreaElement>('#f-notes');
|
||||
if (!btn || !ta) return;
|
||||
|
||||
const key = `notesMono.${opts.itemId || '__new__'}`;
|
||||
|
||||
// chrome.storage may be absent in test environments — guard gracefully.
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
||||
const stored = await new Promise<boolean>((resolve) => {
|
||||
chrome.storage.local.get([key], (result) => resolve(!!result[key]));
|
||||
});
|
||||
if (stored) ta.classList.add('f-notes--mono');
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const next = !ta.classList.contains('f-notes--mono');
|
||||
ta.classList.toggle('f-notes--mono', next);
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
||||
chrome.storage.local.set({ [key]: next }, () => { /* fire and forget */ });
|
||||
}
|
||||
});
|
||||
}
|
||||
67
extension/src/shared/form-affordances/password-tools.ts
Normal file
67
extension/src/shared/form-affordances/password-tools.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { GLYPH_REVEAL, GLYPH_HIDE } from '../glyphs';
|
||||
import { STRENGTH_LABELS, entropyText, type Strength } from '../../setup/setup-helpers';
|
||||
|
||||
/// Returns a teardown fn the caller must invoke on unmount.
|
||||
export function wirePasswordReveal(form: HTMLElement): () => void {
|
||||
const btn = form.querySelector<HTMLButtonElement>('#reveal-password-btn');
|
||||
const input = form.querySelector<HTMLInputElement>('#f-password');
|
||||
if (!btn || !input) return () => {};
|
||||
|
||||
const handler = () => {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
btn.textContent = GLYPH_HIDE;
|
||||
btn.title = 'hide';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
btn.textContent = GLYPH_REVEAL;
|
||||
btn.title = 'reveal';
|
||||
}
|
||||
};
|
||||
btn.addEventListener('click', handler);
|
||||
|
||||
return () => {
|
||||
btn.removeEventListener('click', handler);
|
||||
input.type = 'password';
|
||||
btn.textContent = GLYPH_REVEAL;
|
||||
btn.title = 'reveal';
|
||||
};
|
||||
}
|
||||
|
||||
export interface PasswordStrengthOpts {
|
||||
scheduleRate: (passphrase: string, cb: (s: Strength) => void) => void;
|
||||
}
|
||||
|
||||
export function wirePasswordStrength(form: HTMLElement, opts: PasswordStrengthOpts): void {
|
||||
const input = form.querySelector<HTMLInputElement>('#f-password');
|
||||
const row = form.querySelector<HTMLElement>('#strength-bar-row');
|
||||
if (!input || !row) return;
|
||||
const bar = row.querySelector<HTMLElement>('.strength-bar');
|
||||
const label = row.querySelector<HTMLElement>('.strength-label');
|
||||
if (!bar || !label) return;
|
||||
|
||||
const update = () => {
|
||||
const v = input.value;
|
||||
if (!v) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
opts.scheduleRate(v, (s) => {
|
||||
if (s.score < 0) { row.hidden = true; return; }
|
||||
row.hidden = false;
|
||||
// Reset score classes, then add the current one to the bar element.
|
||||
bar.className = 'strength-bar';
|
||||
const cls = STRENGTH_LABELS[s.score]?.cls ?? 's-very-weak';
|
||||
bar.classList.add(cls);
|
||||
// Light up segments 0..score (5-segment bar).
|
||||
Array.from(bar.children).forEach((seg, i) => {
|
||||
(seg as HTMLElement).classList.toggle('lit', i <= s.score);
|
||||
});
|
||||
const text = STRENGTH_LABELS[s.score]?.text ?? '?';
|
||||
label.textContent = `${text} · ${entropyText(s.guessesLog10)}`;
|
||||
});
|
||||
};
|
||||
|
||||
input.addEventListener('input', update);
|
||||
update();
|
||||
}
|
||||
142
extension/src/shared/form-affordances/totp-tools.ts
Normal file
142
extension/src/shared/form-affordances/totp-tools.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export interface TotpPreviewOpts {
|
||||
sendMessage: (msg: { type: 'preview_totp_from_secret'; secret_b32: string }) =>
|
||||
Promise<{ ok: boolean; data?: { code: string; expires_at: number }; error?: string }>;
|
||||
}
|
||||
|
||||
const VALID_B32 = /^[A-Z2-7]{16,}=*$/;
|
||||
|
||||
export function wireTotpPreview(form: HTMLElement, opts: TotpPreviewOpts): () => void {
|
||||
const input = form.querySelector<HTMLInputElement>('#f-totp');
|
||||
const row = form.querySelector<HTMLElement>('#totp-preview-row');
|
||||
if (!input || !row) return () => {};
|
||||
const codeEl = row.querySelector<HTMLElement>('.totp-code');
|
||||
const cdEl = row.querySelector<HTMLElement>('.totp-countdown');
|
||||
if (!codeEl || !cdEl) return () => {};
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastSecret = '';
|
||||
|
||||
const tick = async () => {
|
||||
const cleaned = lastSecret.toUpperCase().replace(/\s+/g, '').replace(/=+$/, '');
|
||||
if (!VALID_B32.test(cleaned)) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
const resp = await opts.sendMessage({ type: 'preview_totp_from_secret', secret_b32: cleaned });
|
||||
if (!resp.ok || !resp.data) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
row.hidden = false;
|
||||
// Format "492837" → "492 837" for legibility.
|
||||
codeEl.textContent = resp.data.code.length === 6
|
||||
? `${resp.data.code.slice(0, 3)} ${resp.data.code.slice(3)}`
|
||||
: resp.data.code;
|
||||
const remaining = Math.max(0, resp.data.expires_at - Math.floor(Date.now() / 1000));
|
||||
cdEl.textContent = `${remaining}s`;
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
lastSecret = input.value;
|
||||
void tick();
|
||||
};
|
||||
input.addEventListener('input', onInput);
|
||||
if (interval === null) {
|
||||
interval = setInterval(() => { void tick(); }, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
input.removeEventListener('input', onInput);
|
||||
if (interval !== null) { clearInterval(interval); interval = null; }
|
||||
row.hidden = true;
|
||||
};
|
||||
}
|
||||
|
||||
/// Lazy-load jsqr and decode a QR from a Blob/File. Returns the decoded
|
||||
/// string, or null if no QR was found.
|
||||
async function defaultDecodeQrFromBlob(blob: Blob): Promise<string | null> {
|
||||
const [{ default: jsQR }] = await Promise.all([import('jsqr')]);
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
return result?.data ?? null;
|
||||
}
|
||||
|
||||
export interface TotpQrOpts {
|
||||
/// Inject a stub in tests where canvas + imports aren't available.
|
||||
decodeQrFromBlob?: (blob: Blob) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export function wireTotpQr(form: HTMLElement, opts: TotpQrOpts = {}): void {
|
||||
const btn = form.querySelector<HTMLButtonElement>('#totp-qr-btn');
|
||||
const panel = form.querySelector<HTMLElement>('#totp-qr-panel');
|
||||
const fileInput = form.querySelector<HTMLInputElement>('#totp-qr-file');
|
||||
const errEl = form.querySelector<HTMLElement>('#totp-qr-error');
|
||||
const totpInput = form.querySelector<HTMLInputElement>('#f-totp');
|
||||
if (!btn || !panel || !fileInput || !errEl || !totpInput) return;
|
||||
|
||||
const decode = opts.decodeQrFromBlob ?? defaultDecodeQrFromBlob;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
errEl.textContent = '';
|
||||
});
|
||||
|
||||
const handleBlob = async (blob: Blob) => {
|
||||
errEl.textContent = '';
|
||||
let decoded: string | null;
|
||||
try {
|
||||
decoded = await decode(blob);
|
||||
} catch (e) {
|
||||
errEl.textContent = `decode failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
return;
|
||||
}
|
||||
if (!decoded) {
|
||||
errEl.textContent = 'no QR found in image';
|
||||
return;
|
||||
}
|
||||
if (!decoded.startsWith('otpauth://')) {
|
||||
errEl.textContent = 'not a TOTP URI (expected otpauth://...)';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const u = new URL(decoded);
|
||||
const secret = u.searchParams.get('secret');
|
||||
if (!secret) {
|
||||
errEl.textContent = 'TOTP URI missing secret';
|
||||
return;
|
||||
}
|
||||
totpInput.value = secret;
|
||||
totpInput.dispatchEvent(new Event('input', { bubbles: true })); // trigger preview
|
||||
panel.hidden = true;
|
||||
} catch {
|
||||
errEl.textContent = 'TOTP URI did not parse';
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const f = fileInput.files?.[0];
|
||||
if (f) void handleBlob(f);
|
||||
});
|
||||
|
||||
panel.addEventListener('paste', (e) => {
|
||||
const item = Array.from((e as ClipboardEvent).clipboardData?.items ?? []).find((i) => i.type.startsWith('image/'));
|
||||
if (item) {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) void handleBlob(blob);
|
||||
}
|
||||
});
|
||||
|
||||
panel.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||
panel.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const f = (e as DragEvent).dataTransfer?.files?.[0];
|
||||
if (f) void handleBlob(f);
|
||||
});
|
||||
}
|
||||
79
extension/src/shared/form-affordances/url-tools.ts
Normal file
79
extension/src/shared/form-affordances/url-tools.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { GLYPH_FILL_FROM_TAB } from '../glyphs';
|
||||
|
||||
export interface FillFromTabOpts {
|
||||
sendMessage: (msg: { type: 'get_active_tab_url' }) => Promise<{ ok: boolean; data: { url: string; title: string } | null }>;
|
||||
}
|
||||
|
||||
export function wireFillFromTab(form: HTMLElement, opts: FillFromTabOpts): void {
|
||||
const btn = form.querySelector<HTMLButtonElement>('#fill-from-tab-btn');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', async () => {
|
||||
const resp = await opts.sendMessage({ type: 'get_active_tab_url' });
|
||||
if (!resp.ok || !resp.data) {
|
||||
btn.disabled = true;
|
||||
btn.title = 'no active tab';
|
||||
return;
|
||||
}
|
||||
const urlEl = form.querySelector<HTMLInputElement>('#f-url');
|
||||
const titleEl = form.querySelector<HTMLInputElement>('#f-title');
|
||||
if (urlEl) urlEl.value = resp.data.url;
|
||||
if (titleEl && !titleEl.value.trim()) titleEl.value = resp.data.title;
|
||||
});
|
||||
}
|
||||
|
||||
export const FILL_FROM_TAB_BTN_HTML = `<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">${GLYPH_FILL_FROM_TAB}</button>`;
|
||||
|
||||
const CHIP_HUES = [
|
||||
'#5ea0c4', '#c47e5e', '#5ec47a', '#c45e9c',
|
||||
'#a3c45e', '#7e5ec4', '#c4b75e', '#5ec4c4',
|
||||
];
|
||||
|
||||
function hostnameHue(host: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < host.length; i++) h = (h * 31 + host.charCodeAt(i)) | 0;
|
||||
return CHIP_HUES[Math.abs(h) % CHIP_HUES.length];
|
||||
}
|
||||
|
||||
function tryParseHost(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
try {
|
||||
const u = new URL(candidate);
|
||||
const host = u.host || null;
|
||||
if (!host) return null;
|
||||
// Validate hostname contains only valid characters
|
||||
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test(host)) {
|
||||
return null;
|
||||
}
|
||||
return host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function wireHostnameChip(form: HTMLElement): void {
|
||||
const input = form.querySelector<HTMLInputElement>('#f-url');
|
||||
const row = form.querySelector<HTMLElement>('#hostname-chip-row');
|
||||
if (!input || !row) return;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const update = () => {
|
||||
const host = tryParseHost(input.value);
|
||||
if (!host) {
|
||||
row.hidden = true;
|
||||
row.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const initial = host[0]?.toUpperCase() ?? '?';
|
||||
const hue = hostnameHue(host);
|
||||
row.hidden = false;
|
||||
row.innerHTML = `<span class="hostname-chip" style="background:${hue};">${initial}</span><span class="hostname-text">${host}</span>`;
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = setTimeout(() => { timer = null; update(); }, 200);
|
||||
});
|
||||
update(); // initial render for prefilled values
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export type PopupMessage =
|
||||
| { type: 'update_vault_settings'; settings: VaultSettings }
|
||||
| { type: 'get_blacklist' }
|
||||
| { type: 'remove_blacklist'; hostname: string }
|
||||
| { type: 'get_active_tab_url' }
|
||||
| { type: 'list_groups' }
|
||||
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
|
||||
| { type: 'download_attachment'; itemId: string; attachmentId: string }
|
||||
| { type: 'list_devices' }
|
||||
@@ -57,7 +59,8 @@ export type PopupMessage =
|
||||
newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string };
|
||||
}
|
||||
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
|
||||
| { type: 'import_lastpass_commit'; items: Item[] };
|
||||
| { type: 'import_lastpass_commit'; items: Item[] }
|
||||
| { type: 'preview_totp_from_secret'; secret_b32: string };
|
||||
|
||||
// --- Messages a content script may send ---
|
||||
|
||||
@@ -157,13 +160,14 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
'fill_credentials',
|
||||
'ack_autofill_origin', 'get_settings', 'update_settings',
|
||||
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
||||
'remove_blacklist', 'upload_attachment', 'download_attachment',
|
||||
'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment',
|
||||
'list_devices', 'add_device', 'register_this_device', 'revoke_device',
|
||||
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
|
||||
'get_field_history',
|
||||
'get_session_config', 'update_session_config',
|
||||
'export_backup', 'restore_backup',
|
||||
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||
'preview_totp_from_secret',
|
||||
] as PopupMessage['type'][]);
|
||||
|
||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
--bg-page: #0d1117;
|
||||
--bg-pane: #161b22;
|
||||
--bg-elevated: #21262d;
|
||||
--bg-input: #161b22;
|
||||
--border-subtle: #30363d;
|
||||
|
||||
/* Text */
|
||||
@@ -1362,3 +1363,127 @@ textarea {
|
||||
.vault-lock-screen__form input {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Glyph button used by smart-input affordances. Sits inline with an input. */
|
||||
.glyph-btn {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.glyph-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.glyph-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.glyph-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hostname-chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.hostname-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #0c1118;
|
||||
}
|
||||
.hostname-text {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.strength-bar-row {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 4px;
|
||||
}
|
||||
.strength-bar > span {
|
||||
flex: 1;
|
||||
background: var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.strength-bar.s-very-weak > span.lit { background: #c75a4f; }
|
||||
.strength-bar.s-weak > span.lit { background: #c75a4f; }
|
||||
.strength-bar.s-fair > span.lit { background: #d49b3a; }
|
||||
.strength-bar.s-good > span.lit { background: #d49b3a; }
|
||||
.strength-bar.s-strong > span.lit { background: #6cb37a; }
|
||||
.strength-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.totp-preview {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-code {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.totp-countdown {
|
||||
font-size: 11px;
|
||||
}
|
||||
.totp-qr-panel {
|
||||
margin-top: 6px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.totp-qr-panel input[type="file"] {
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-qr-error {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--danger, #c75a4f);
|
||||
}
|
||||
.notes-with-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.f-notes--mono {
|
||||
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user