Merge Plan 1C-β₁: typed-item forms
Adds the 5 remaining typed-item forms (SecureNote, Identity, Card, Key,
Totp incl. Steam Guard) to the browser extension. Document type stays
deferred to γ pending attachment upload. 12 commits across 5 slices
+ 3 mid-slice fixes for issues caught in code review.
Slice 1: Rust Steam alphabet in compute_totp_code (4 tests, +4).
Slice 2: shared field-helpers module + Login refactor onto it (13
helper tests; Login is the reference impl); plus 3 critical review
fixes — escapeHtml covers " and ', centralized teardown, restore
α's login-detail keyboard shortcuts.
Slice 3: SecureNote + Identity (mechanical).
Slice 4: Card (signature block, MM/YY selects, brand-from-BIN) + Key
(concealed monospace textarea with webkit-text-security mask).
Slice 5: Totp (countdown ring, Steam/TOTP kind toggle); plus SW
get_totp router extension to cover both Login.totp and Totp.config
items (code-review catch — plan assumed α's handler already
supported both).
Slice 6: + New picker with all 7 types in the toolbar; cross-cutting
cleanup of form escHandler leak across all 6 type modules.
Tests: 84 Vitest (was 55) + 155 Rust (was 151). Both Chrome and
Firefox bundles compile clean. All lint greps clean (no @ts-nocheck,
no idfoto refs, no stale 'coming soon' outside Document).
Tag plan-1c-beta1-complete points at 7060515 (branch tip).
This commit is contained in:
@@ -8,6 +8,10 @@ use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Steam Mobile Authenticator's 5-character output alphabet.
|
||||
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
|
||||
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TotpCore {
|
||||
pub config: TotpConfig,
|
||||
@@ -96,6 +100,16 @@ pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<S
|
||||
| ((hmac_out[offset + 1] as u32) << 16)
|
||||
| ((hmac_out[offset + 2] as u32) << 8)
|
||||
| (hmac_out[offset + 3] as u32);
|
||||
if matches!(config.kind, TotpKind::Steam) {
|
||||
let mut t = truncated;
|
||||
let mut out = String::with_capacity(5);
|
||||
for _ in 0..5 {
|
||||
out.push(STEAM_ALPHABET[(t % 26) as usize] as char);
|
||||
t /= 26;
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
let modulus = 10u32.pow(config.digits as u32);
|
||||
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
||||
}
|
||||
@@ -168,3 +182,103 @@ mod tests {
|
||||
assert!(json.contains("steam"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod steam_tests {
|
||||
use super::*;
|
||||
|
||||
/// Reference implementation of the Steam 5-character output, per the
|
||||
/// Steam Mobile Authenticator (and WinAuth's Steam-Guard adapter).
|
||||
/// Used by tests below to cross-check the production impl without
|
||||
/// requiring a third-party vector. The algorithm is short enough to
|
||||
/// be reproduced here in isolation.
|
||||
fn steam_output_reference(truncated: u32) -> String {
|
||||
const ALPHA: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||
let mut t = truncated;
|
||||
let mut out = String::with_capacity(5);
|
||||
for _ in 0..5 {
|
||||
out.push(ALPHA[(t % 26) as usize] as char);
|
||||
t /= 26;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the dynamic-truncated u32 the same way `compute_totp_code`
|
||||
/// does internally — used to drive the reference impl.
|
||||
fn truncated_for(secret: &[u8], counter: u64) -> u32 {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1;
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(secret).unwrap();
|
||||
mac.update(&counter.to_be_bytes());
|
||||
let bytes = mac.finalize().into_bytes();
|
||||
let offset = (bytes[bytes.len() - 1] & 0x0F) as usize;
|
||||
((bytes[offset] as u32 & 0x7F) << 24)
|
||||
| ((bytes[offset + 1] as u32) << 16)
|
||||
| ((bytes[offset + 2] as u32) << 8)
|
||||
| (bytes[offset + 3] as u32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_output_matches_reference_impl() {
|
||||
let secret = b"12345678901234567890".to_vec();
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(secret.clone()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 5,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Steam,
|
||||
};
|
||||
let code_at_30 = compute_totp_code(&cfg, 30).unwrap();
|
||||
let code_at_60 = compute_totp_code(&cfg, 60).unwrap();
|
||||
let code_at_120 = compute_totp_code(&cfg, 120).unwrap();
|
||||
assert_eq!(code_at_30, steam_output_reference(truncated_for(&secret, 1)));
|
||||
assert_eq!(code_at_60, steam_output_reference(truncated_for(&secret, 2)));
|
||||
assert_eq!(code_at_120, steam_output_reference(truncated_for(&secret, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_output_is_exactly_5_chars_regardless_of_digits() {
|
||||
let secret = b"hello world!".to_vec();
|
||||
for digits in [4u8, 5, 6, 7, 8] {
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(secret.clone()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Steam,
|
||||
};
|
||||
let code = compute_totp_code(&cfg, 0).unwrap();
|
||||
assert_eq!(code.len(), 5, "Steam output must be 5 chars (digits={})", digits);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_output_uses_only_alphabet_chars() {
|
||||
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
|
||||
let secret = b"hello world!".to_vec();
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(secret),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 5,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Steam,
|
||||
};
|
||||
for t in 0u64..1000 {
|
||||
let code = compute_totp_code(&cfg, t * 30).unwrap();
|
||||
for ch in code.chars() {
|
||||
assert!(ALPHA.contains(ch), "char {ch:?} not in Steam alphabet (t={t})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_alphabet_excludes_ambiguous_glyphs() {
|
||||
// Authoritative Steam Guard alphabet from Valve's Steam Mobile
|
||||
// Authenticator: 26 chars, excludes 0/O, 1/I/L, S, A, E, U, Z.
|
||||
// (Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous.)
|
||||
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
|
||||
for ch in ['0', 'O', '1', 'I', 'L', 'S', 'A', 'Z'] {
|
||||
assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
149
extension/src/popup/components/__tests__/fields.test.ts
Normal file
149
extension/src/popup/components/__tests__/fields.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
renderRow,
|
||||
renderConcealedRow,
|
||||
renderSignatureBlock,
|
||||
wireFieldHandlers,
|
||||
} from '../fields';
|
||||
|
||||
describe('renderRow', () => {
|
||||
it('plain row contains label + value', () => {
|
||||
const html = renderRow({ label: 'username', value: 'alice' });
|
||||
expect(html).toContain('username');
|
||||
expect(html).toContain('alice');
|
||||
expect(html).toContain('field-row');
|
||||
});
|
||||
|
||||
it('copyable row exposes a copy action', () => {
|
||||
const html = renderRow({ label: 'email', value: 'alice@example.com', copyable: true });
|
||||
expect(html).toContain('data-field-action="copy"');
|
||||
});
|
||||
|
||||
it('href row wraps value in an external anchor', () => {
|
||||
const html = renderRow({ label: 'url', value: 'https://example.com', href: 'https://example.com' });
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
it('monospace flag toggles the monospace class', () => {
|
||||
const html = renderRow({ label: 'fingerprint', value: 'AB:CD', monospace: true });
|
||||
expect(html).toContain('monospace');
|
||||
});
|
||||
|
||||
it('multiline value renders inside a <pre>', () => {
|
||||
const html = renderRow({ label: 'address', value: '1 Main\n2 Main', multiline: true });
|
||||
expect(html).toContain('<pre');
|
||||
});
|
||||
|
||||
it('escapes HTML in value and label', () => {
|
||||
const html = renderRow({ label: '<script>x</script>', value: '"&<>' });
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('&');
|
||||
expect(html).toContain('<');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderConcealedRow', () => {
|
||||
it('initial state hides the value behind a placeholder', () => {
|
||||
const html = renderConcealedRow({ id: 'pw1', label: 'password', value: 'hunter2' });
|
||||
expect(html).toContain('data-field-id="pw1"');
|
||||
expect(html).toContain('data-revealed="false"');
|
||||
expect(html).toContain('••••');
|
||||
// Plaintext is in a data attribute on the row, NOT in the visible textContent.
|
||||
expect(html).not.toMatch(/>hunter2</);
|
||||
});
|
||||
|
||||
it('exposes show + copy actions', () => {
|
||||
const html = renderConcealedRow({ id: 'pw1', label: 'password', value: 'hunter2' });
|
||||
expect(html).toContain('data-field-action="reveal"');
|
||||
expect(html).toContain('data-field-action="copy"');
|
||||
});
|
||||
|
||||
it('multiline concealed shows char count when hidden', () => {
|
||||
const html = renderConcealedRow({ id: 'k1', label: 'key', value: 'abcdefghij', multiline: true });
|
||||
expect(html).toContain('•••• (10 chars)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderSignatureBlock', () => {
|
||||
it('default accent is blue', () => {
|
||||
const html = renderSignatureBlock({ children: '<p>hi</p>' });
|
||||
expect(html).toContain('sig-block--blue');
|
||||
expect(html).toContain('<p>hi</p>');
|
||||
});
|
||||
|
||||
it('honors accent prop', () => {
|
||||
expect(renderSignatureBlock({ accent: 'green', children: '' })).toContain('sig-block--green');
|
||||
expect(renderSignatureBlock({ accent: 'amber', children: '' })).toContain('sig-block--amber');
|
||||
expect(renderSignatureBlock({ accent: 'red', children: '' })).toContain('sig-block--red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireFieldHandlers', () => {
|
||||
it('reveal toggle flips data-revealed and swaps placeholder for plaintext', () => {
|
||||
document.body.innerHTML = renderConcealedRow({
|
||||
id: 'pw1',
|
||||
label: 'password',
|
||||
value: 'hunter2',
|
||||
});
|
||||
wireFieldHandlers(document.body);
|
||||
const row = document.querySelector('[data-field-id="pw1"]') as HTMLElement;
|
||||
const revealBtn = row.querySelector('[data-field-action="reveal"]') as HTMLButtonElement;
|
||||
const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement;
|
||||
expect(row.getAttribute('data-revealed')).toBe('false');
|
||||
expect(valueEl.textContent).toContain('••••');
|
||||
revealBtn.click();
|
||||
expect(row.getAttribute('data-revealed')).toBe('true');
|
||||
expect(valueEl.textContent).toBe('hunter2');
|
||||
});
|
||||
|
||||
it('copy button writes the row value to the clipboard', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
document.body.innerHTML = renderRow({
|
||||
label: 'email',
|
||||
value: 'alice@example.com',
|
||||
copyable: true,
|
||||
});
|
||||
wireFieldHandlers(document.body);
|
||||
const copyBtn = document.querySelector('[data-field-action="copy"]') as HTMLButtonElement;
|
||||
copyBtn.click();
|
||||
expect(writeText).toHaveBeenCalledWith('alice@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concealed-row round-trip with special characters', () => {
|
||||
it('reveals a value containing double quotes correctly', () => {
|
||||
document.body.innerHTML = renderConcealedRow({ id: 'pw', label: 'p', value: 'a"b"c' });
|
||||
wireFieldHandlers(document.body);
|
||||
const btn = document.querySelector('[data-field-action="reveal"]') as HTMLButtonElement;
|
||||
btn.click();
|
||||
const valueEl = document.querySelector('[data-field-role="value"]') as HTMLElement;
|
||||
expect(valueEl.textContent).toBe('a"b"c');
|
||||
});
|
||||
|
||||
it('reveals a value containing single quotes correctly', () => {
|
||||
document.body.innerHTML = renderConcealedRow({ id: 'pw', label: 'p', value: "it's & ok" });
|
||||
wireFieldHandlers(document.body);
|
||||
const btn = document.querySelector('[data-field-action="reveal"]') as HTMLButtonElement;
|
||||
btn.click();
|
||||
const valueEl = document.querySelector('[data-field-role="value"]') as HTMLElement;
|
||||
expect(valueEl.textContent).toBe("it's & ok");
|
||||
});
|
||||
|
||||
it('copies a value containing double quotes correctly', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
document.body.innerHTML = renderRow({ label: 'p', value: 'a"b"c', copyable: true });
|
||||
wireFieldHandlers(document.body);
|
||||
(document.querySelector('[data-field-action="copy"]') as HTMLButtonElement).click();
|
||||
expect(writeText).toHaveBeenCalledWith('a"b"c');
|
||||
});
|
||||
});
|
||||
119
extension/src/popup/components/fields.ts
Normal file
119
extension/src/popup/components/fields.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/// Field rendering primitives used by every typed-item detail view.
|
||||
///
|
||||
/// Pure functions that return HTML strings. Caller is responsible for
|
||||
/// mounting the strings into the DOM (typically via `app.innerHTML = ...`).
|
||||
/// After mounting, call `wireFieldHandlers(scope)` once to bind reveal +
|
||||
/// copy click handlers on any rendered rows.
|
||||
|
||||
import { escapeHtml } from '../popup';
|
||||
|
||||
export interface RowOpts {
|
||||
label: string;
|
||||
value: string;
|
||||
copyable?: boolean;
|
||||
href?: string;
|
||||
monospace?: boolean;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
/// Plain label/value row. Optional copy button, optional anchor wrap,
|
||||
/// optional monospace styling, optional multiline (renders in a <pre>).
|
||||
export function renderRow(opts: RowOpts): string {
|
||||
const { label, value, copyable, href, monospace, multiline } = opts;
|
||||
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
||||
let valueHtml: string;
|
||||
if (multiline) {
|
||||
valueHtml = `<pre>${escapeHtml(value)}</pre>`;
|
||||
} else if (href) {
|
||||
valueHtml = `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(value)}</a>`;
|
||||
} else {
|
||||
valueHtml = escapeHtml(value);
|
||||
}
|
||||
const actions = copyable
|
||||
? `<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="field-row">
|
||||
<span class="field-row__label">${escapeHtml(label)}</span>
|
||||
<span class="${valueClass}" data-field-role="value">${valueHtml}</span>
|
||||
<span class="field-row__actions">${actions}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export interface ConcealedRowOpts {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
monospace?: boolean;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
||||
const { id, label, value, monospace, multiline } = opts;
|
||||
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
|
||||
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
||||
return `
|
||||
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}">
|
||||
<span class="field-row__label">${escapeHtml(label)}</span>
|
||||
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
||||
<span class="field-row__actions">
|
||||
<button type="button" data-field-action="reveal">show</button>
|
||||
<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export interface SignatureBlockOpts {
|
||||
accent?: 'blue' | 'green' | 'amber' | 'red';
|
||||
children: string;
|
||||
}
|
||||
|
||||
/// Container for the type-specific signature panel. `children` is HTML
|
||||
/// the caller has already produced (and escaped where needed).
|
||||
export function renderSignatureBlock(opts: SignatureBlockOpts): string {
|
||||
const accent = opts.accent ?? 'blue';
|
||||
return `
|
||||
<div class="sig-block sig-block--${accent}">${opts.children}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/// Wire reveal-toggle + copy click handlers within `scope`. Idempotent —
|
||||
/// safe to call multiple times against the same scope.
|
||||
export function wireFieldHandlers(scope: HTMLElement): void {
|
||||
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="reveal"]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('[data-field-id]') as HTMLElement | null;
|
||||
if (!row) return;
|
||||
const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement | null;
|
||||
if (!valueEl) return;
|
||||
const revealed = row.getAttribute('data-revealed') === 'true';
|
||||
const plaintext = row.getAttribute('data-field-value') ?? '';
|
||||
const multiline = row.getAttribute('data-field-multiline') === 'true';
|
||||
if (revealed) {
|
||||
const placeholder = multiline ? `•••• (${plaintext.length} chars)` : '••••';
|
||||
valueEl.textContent = placeholder;
|
||||
row.setAttribute('data-revealed', 'false');
|
||||
btn.textContent = 'show';
|
||||
} else {
|
||||
valueEl.textContent = plaintext;
|
||||
row.setAttribute('data-revealed', 'true');
|
||||
btn.textContent = 'hide';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="copy"]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const value = btn.getAttribute('data-field-value') ?? '';
|
||||
try { await navigator.clipboard.writeText(value); } catch { /* swallow — UX is the visual flash below */ }
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'copied';
|
||||
setTimeout(() => { if (btn.textContent === 'copied') btn.textContent = original; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,374 +1,48 @@
|
||||
/// Typed-item detail view — dispatches on `item.type`. Slice 6 delivers
|
||||
/// full Login parity; all other types show a "coming soon" placeholder.
|
||||
///
|
||||
/// Autofill uses the (capturedTabId, capturedUrl) pair snapshotted at
|
||||
/// popup-open (see PopupState + router/popup-only.ts#handleFillCredentials)
|
||||
/// so the SW can reject the fill if the tab navigated.
|
||||
/// Typed-item detail view dispatcher. Each type's renderDetail lives in
|
||||
/// its own module under ./types/. Document stays "coming soon" until γ.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { Item, ItemId, ManifestEntry, LoginCore, TotpConfig } from '../../shared/types';
|
||||
import { navigate } from '../popup';
|
||||
import type { Item } from '../../shared/types';
|
||||
import { getState } from '../popup';
|
||||
import * as login from './types/login';
|
||||
import * as secureNote from './types/secure-note';
|
||||
import * as identity from './types/identity';
|
||||
import * as card from './types/card';
|
||||
import * as key from './types/key';
|
||||
import * as totp from './types/totp';
|
||||
|
||||
let totpInterval: ReturnType<typeof setInterval> | null = null;
|
||||
export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
||||
// Tear down any tickers/handlers from a previous detail render before
|
||||
// the next one boots up. Each type module owns its own teardown; we
|
||||
// call all of them since the dispatcher doesn't know which was active.
|
||||
login.teardown();
|
||||
secureNote.teardown();
|
||||
identity.teardown();
|
||||
card.teardown();
|
||||
key.teardown();
|
||||
totp.teardown();
|
||||
|
||||
function stopTotpTimer(): void {
|
||||
if (totpInterval !== null) {
|
||||
clearInterval(totpInterval);
|
||||
totpInterval = null;
|
||||
const item = getState().selectedItem;
|
||||
if (!item) { navigate('list'); return; }
|
||||
|
||||
switch (item.type) {
|
||||
case 'login': return login.renderDetail(app, item);
|
||||
case 'secure_note': return secureNote.renderDetail(app, item);
|
||||
case 'identity': return identity.renderDetail(app, item);
|
||||
case 'card': return card.renderDetail(app, item);
|
||||
case 'key': return key.renderDetail(app, item);
|
||||
case 'totp': return totp.renderDetail(app, item);
|
||||
case 'document': return renderComingSoon(app, item);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// Fallback for older browsers.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderItemDetail(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
const item = state.selectedItem;
|
||||
if (!item) {
|
||||
navigate('list');
|
||||
return;
|
||||
}
|
||||
|
||||
stopTotpTimer();
|
||||
|
||||
if (item.type === 'login') {
|
||||
renderLogin(app, item);
|
||||
} else {
|
||||
renderComingSoon(app, item);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Login detail ------------------------------------------------------
|
||||
|
||||
function renderLogin(app: HTMLElement, item: Item): void {
|
||||
const core = item.core as (LoginCore & { type: 'login' });
|
||||
const hasTotp = core.totp !== undefined;
|
||||
|
||||
let html = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-title">${escapeHtml(item.title)}</span>
|
||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (core.url) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">url</div>
|
||||
<div class="field-value"><a href="${escapeHtml(core.url)}" target="_blank" rel="noopener noreferrer" style="color:#58a6ff; text-decoration:none;">${escapeHtml(core.url)}</a></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (core.username) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">username</div>
|
||||
<div class="field-value" id="username-val" style="cursor:pointer;" title="click to copy">${escapeHtml(core.username)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">password</div>
|
||||
<div class="field-value" id="password-val" style="cursor:pointer;" title="click to toggle">
|
||||
<span id="password-display">********</span>
|
||||
<button class="btn" id="password-copy" style="font-size:10px; margin-left:8px; padding:2px 6px;">copy</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (hasTotp) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">totp</div>
|
||||
<div class="totp-code" id="totp-code">------</div>
|
||||
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (item.notes) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">notes</div>
|
||||
<div class="field-value" style="white-space:pre-wrap;">${escapeHtml(item.notes)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (item.group) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">group</div>
|
||||
<div class="field-value">${escapeHtml(item.group)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="muted">modified ${escapeHtml(new Date(item.modified * 1000).toISOString())}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += `
|
||||
<div class="form-actions" style="padding:8px 12px;">
|
||||
<button class="btn btn-primary" id="fill-btn">autofill</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn btn-danger" id="trash-btn" style="margin-left:auto;">trash</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += `
|
||||
<div class="keyhints">
|
||||
<span><kbd>c</kbd> copy user</span>
|
||||
<span><kbd>p</kbd> copy pass</span>
|
||||
${hasTotp ? '<span><kbd>t</kbd> copy totp</span>' : ''}
|
||||
<span><kbd>f</kbd> autofill</span>
|
||||
<span><kbd>e</kbd> edit</span>
|
||||
<span><kbd>d</kbd> trash</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = html;
|
||||
|
||||
// --- Password toggle ---
|
||||
let passwordVisible = false;
|
||||
const passwordDisplay = document.getElementById('password-display');
|
||||
const passwordVal = document.getElementById('password-val');
|
||||
const password = core.password ?? '';
|
||||
passwordVal?.addEventListener('click', (e) => {
|
||||
// Ignore clicks originating on the copy button.
|
||||
if ((e.target as HTMLElement).id === 'password-copy') return;
|
||||
passwordVisible = !passwordVisible;
|
||||
if (passwordDisplay) passwordDisplay.textContent = passwordVisible ? password : '********';
|
||||
});
|
||||
document.getElementById('password-copy')?.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await copyToClipboard(password);
|
||||
});
|
||||
|
||||
if (core.username) {
|
||||
document.getElementById('username-val')?.addEventListener('click', async () => {
|
||||
await copyToClipboard(core.username ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', goBack);
|
||||
|
||||
document.getElementById('fill-btn')?.addEventListener('click', async () => {
|
||||
const { capturedTabId, capturedUrl } = getState();
|
||||
if (capturedTabId === null) {
|
||||
setState({ error: 'No active tab captured' });
|
||||
return;
|
||||
}
|
||||
const resp = await sendMessage({
|
||||
type: 'fill_credentials',
|
||||
id: item.id,
|
||||
capturedTabId,
|
||||
capturedUrl,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setState({ error: resp.error });
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
});
|
||||
|
||||
document.getElementById('edit-btn')?.addEventListener('click', () => {
|
||||
document.removeEventListener('keydown', handler);
|
||||
stopTotpTimer();
|
||||
navigate('edit');
|
||||
});
|
||||
|
||||
document.getElementById('trash-btn')?.addEventListener('click', () => {
|
||||
showDeleteConfirm(item.id, item.title, handler);
|
||||
});
|
||||
|
||||
// --- TOTP timer ---
|
||||
if (hasTotp) {
|
||||
void refreshTotp(item.id);
|
||||
totpInterval = setInterval(() => { void refreshTotp(item.id); }, 1000);
|
||||
}
|
||||
|
||||
// --- Keyboard shortcuts ---
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
// Bail if the user is typing into any editable field — don't steal
|
||||
// printable keystrokes meant for an input/textarea/contenteditable element.
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
document.removeEventListener('keydown', handler);
|
||||
goBack();
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
if (core.username) await copyToClipboard(core.username);
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
await copyToClipboard(password);
|
||||
break;
|
||||
|
||||
case 't':
|
||||
if (hasTotp) {
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
if (codeEl) await copyToClipboard(codeEl.textContent ?? '');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'f': {
|
||||
const { capturedTabId, capturedUrl } = getState();
|
||||
if (capturedTabId === null) {
|
||||
setState({ error: 'No active tab captured' });
|
||||
break;
|
||||
}
|
||||
const resp = await sendMessage({
|
||||
type: 'fill_credentials',
|
||||
id: item.id,
|
||||
capturedTabId,
|
||||
capturedUrl,
|
||||
});
|
||||
if (!resp.ok) setState({ error: resp.error });
|
||||
else window.close();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'e':
|
||||
document.removeEventListener('keydown', handler);
|
||||
stopTotpTimer();
|
||||
navigate('edit');
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
showDeleteConfirm(item.id, item.title, handler);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
async function refreshTotp(id: ItemId): Promise<void> {
|
||||
const resp = await sendMessage({ type: 'get_totp', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { code: string; expires_at: number };
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
const barEl = document.getElementById('totp-bar-fill');
|
||||
if (codeEl) codeEl.textContent = data.code;
|
||||
if (barEl) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const remaining = Math.max(0, data.expires_at - now);
|
||||
// Period is 30 by default; compute ratio against 30.
|
||||
barEl.style.width = `${(remaining / 30) * 100}%`;
|
||||
}
|
||||
}
|
||||
// Suppress unused warning; TotpConfig referenced for typing only below.
|
||||
void ({} as TotpConfig);
|
||||
}
|
||||
|
||||
// --- Coming-soon for non-login types -----------------------------------
|
||||
|
||||
function renderComingSoon(app: HTMLElement, item: Item): void {
|
||||
app.innerHTML = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-title">${escapeHtml(item.title)}</span>
|
||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||
</div>
|
||||
<div class="pad" style="text-align:center; padding:32px 16px;">
|
||||
<div style="font-size:32px; margin-bottom:12px;">${typeEmoji(item.type)}</div>
|
||||
<div style="font-size:14px; color:#c9d1d9; margin-bottom:4px;">${escapeHtml(item.type.replace('_', ' '))}</div>
|
||||
<p class="muted">read/write for this type is coming in a later slice.</p>
|
||||
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${item.title}</div>
|
||||
<p class="muted">The <strong>${item.type}</strong> item type is not editable in the extension yet.</p>
|
||||
<div class="form-actions"><button class="btn" id="back-btn">back</button></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', goBack);
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handler);
|
||||
goBack();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
function typeEmoji(t: Item['type']): string {
|
||||
switch (t) {
|
||||
case 'login': return '🔑';
|
||||
case 'secure_note': return '📝';
|
||||
case 'identity': return '🪪';
|
||||
case 'card': return '💳';
|
||||
case 'key': return '🗝';
|
||||
case 'document': return '📄';
|
||||
case 'totp': return '⏱';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared helpers ----------------------------------------------------
|
||||
|
||||
function goBack(): void {
|
||||
stopTotpTimer();
|
||||
// Reload the item list.
|
||||
void sendMessage({ type: 'list_items' }).then(resp => {
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', {
|
||||
entries: data.items,
|
||||
selectedId: null,
|
||||
selectedItem: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteConfirm(id: ItemId, title: string, parentHandler: (e: KeyboardEvent) => void): void {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-box">
|
||||
<p>Trash <strong>${escapeHtml(title)}</strong>?</p>
|
||||
<button class="btn" id="cancel-delete">cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-delete">trash</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('cancel-delete')?.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
document.getElementById('confirm-delete')?.addEventListener('click', async () => {
|
||||
overlay.remove();
|
||||
setState({ loading: true });
|
||||
const resp = await sendMessage({ type: 'delete_item', id });
|
||||
if (resp.ok) {
|
||||
document.removeEventListener('keydown', parentHandler);
|
||||
stopTotpTimer();
|
||||
goBack();
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
});
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
}
|
||||
|
||||
@@ -1,281 +1,44 @@
|
||||
/// Typed-item add/edit form. Slice 6 ships full Login parity; other
|
||||
/// types show a coming-soon placeholder (use the CLI for now).
|
||||
///
|
||||
/// Carry-forward from Slice 5 review M3: on edit, trashed_at is
|
||||
/// explicitly reset to undefined so stale trash state cannot survive an
|
||||
/// edit. (The capture path already uses spread + fetched item; this
|
||||
/// popup flow uses state.selectedItem.)
|
||||
/// Typed-item add/edit form dispatcher. Each type's renderForm lives in
|
||||
/// its own module under ./types/. Document stays "coming soon" until γ.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type {
|
||||
Item, ItemId, ItemType, ManifestEntry, LoginCore, TotpConfig,
|
||||
} from '../../shared/types';
|
||||
import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types';
|
||||
import { base32Decode, base32Encode } from '../../shared/base32';
|
||||
|
||||
// Which types support add/edit in Slice 6.
|
||||
function isEditableType(t: ItemType): boolean {
|
||||
return t === 'login';
|
||||
}
|
||||
import { navigate, getState } from '../popup';
|
||||
import type { Item, ItemType } from '../../shared/types';
|
||||
import * as login from './types/login';
|
||||
import * as secureNote from './types/secure-note';
|
||||
import * as identity from './types/identity';
|
||||
import * as card from './types/card';
|
||||
import * as key from './types/key';
|
||||
import * as totp from './types/totp';
|
||||
|
||||
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
login.teardown(); // detail-view's ticker/listener don't leak into form
|
||||
secureNote.teardown();
|
||||
identity.teardown();
|
||||
card.teardown();
|
||||
key.teardown();
|
||||
totp.teardown();
|
||||
const state = getState();
|
||||
const existing = mode === 'edit' ? state.selectedItem : null;
|
||||
const type: ItemType = existing?.type ?? state.newType ?? 'login';
|
||||
|
||||
// Determine the type we're editing/creating. Add defaults to login.
|
||||
const type: ItemType = existing?.type ?? 'login';
|
||||
|
||||
if (!isEditableType(type)) {
|
||||
renderComingSoon(app, type);
|
||||
return;
|
||||
switch (type) {
|
||||
case 'login': return login.renderForm(app, mode, existing);
|
||||
case 'secure_note': return secureNote.renderForm(app, mode, existing);
|
||||
case 'identity': return identity.renderForm(app, mode, existing);
|
||||
case 'card': return card.renderForm(app, mode, existing);
|
||||
case 'key': return key.renderForm(app, mode, existing);
|
||||
case 'totp': return totp.renderForm(app, mode, existing);
|
||||
case 'document': return renderComingSoon(app, type);
|
||||
}
|
||||
|
||||
renderLoginForm(app, mode, existing);
|
||||
}
|
||||
|
||||
// --- Coming-soon -------------------------------------------------------
|
||||
|
||||
function renderComingSoon(app: HTMLElement, type: ItemType): void {
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${escapeHtml(type.replace('_', ' '))}</div>
|
||||
<p class="muted">editing ${escapeHtml(type)} items is coming in a later slice.</p>
|
||||
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
</div>
|
||||
<div class="detail-title" style="margin-bottom:16px;">${type.replace('_', ' ')}</div>
|
||||
<p class="muted">Editing <strong>${type}</strong> items is not available yet.</p>
|
||||
<div class="form-actions"><button class="btn" id="back-btn">back</button></div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handler);
|
||||
navigate('list');
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// --- Login add/edit ----------------------------------------------------
|
||||
|
||||
/// Encode TotpConfig secret bytes back to a base32 display string.
|
||||
function totpSecretToBase32(totp: TotpConfig | undefined): string {
|
||||
if (!totp) return '';
|
||||
return base32Encode(new Uint8Array(totp.secret));
|
||||
}
|
||||
|
||||
function renderLoginForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const existingCore = (existing?.core.type === 'login')
|
||||
? (existing.core as LoginCore & { type: 'login' })
|
||||
: null;
|
||||
|
||||
const title = existing?.title ?? '';
|
||||
const url = existingCore?.url ?? '';
|
||||
const username = existingCore?.username ?? '';
|
||||
const password = existingCore?.password ?? '';
|
||||
const totpStr = totpSecretToBase32(existingCore?.totp);
|
||||
const group = existing?.group ?? '';
|
||||
const notes = existing?.notes ?? '';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-title">title *</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-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="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button class="btn" id="gen-btn" title="generate">gen</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>
|
||||
<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-actions">
|
||||
<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>
|
||||
`;
|
||||
|
||||
// --- Generate password ---
|
||||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
||||
const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { password: string };
|
||||
const pwInput = document.getElementById('f-password') as HTMLInputElement;
|
||||
pwInput.value = data.password;
|
||||
pwInput.type = 'text'; // Show generated password.
|
||||
} else {
|
||||
setState({ error: resp.error });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Cancel ---
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => goBack(mode));
|
||||
|
||||
// --- Save ---
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveLogin(mode, existing);
|
||||
});
|
||||
|
||||
// --- Escape to cancel ---
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
goBack(mode);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
// Focus the title field.
|
||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||
}
|
||||
|
||||
function goBack(mode: 'add' | 'edit'): void {
|
||||
const s = getState();
|
||||
if (mode === 'edit' && s.selectedId && s.selectedItem) {
|
||||
navigate('detail');
|
||||
} else {
|
||||
navigate('list');
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a URL input so the Rust-side `url::Url::parse` accepts it.
|
||||
///
|
||||
/// Prepends `https://` when the input looks like a bare host (no scheme),
|
||||
/// then validates via the JS URL constructor. Returns { ok, value, error }.
|
||||
function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } {
|
||||
if (!raw) return { ok: true, value: '' };
|
||||
const trimmed = raw.trim();
|
||||
// If it already has a scheme, pass through. Otherwise assume https://.
|
||||
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
try {
|
||||
const u = new URL(candidate);
|
||||
// url::Url rejects schemes without an authority (host). Require a host.
|
||||
if (!u.host) return { ok: false, error: 'URL must include a host (e.g. https://example.com)' };
|
||||
return { ok: true, value: u.toString() };
|
||||
} catch {
|
||||
return { ok: false, error: 'URL is not valid — try something like https://example.com' };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
||||
const username = (document.getElementById('f-username') as HTMLInputElement).value.trim();
|
||||
const password = (document.getElementById('f-password') as HTMLInputElement).value;
|
||||
const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
|
||||
const group = (document.getElementById('f-group') as HTMLInputElement).value.trim();
|
||||
const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value;
|
||||
|
||||
if (!title) {
|
||||
setState({ error: 'Title is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const urlResult = normalizeUrl(rawUrl);
|
||||
if (!urlResult.ok) {
|
||||
setState({ error: urlResult.error });
|
||||
return;
|
||||
}
|
||||
const url = urlResult.value;
|
||||
|
||||
let totp: TotpConfig | undefined;
|
||||
if (totpStr) {
|
||||
try {
|
||||
const bytes = base32Decode(totpStr);
|
||||
totp = {
|
||||
secret: Array.from(bytes),
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: 'totp',
|
||||
};
|
||||
} catch (err) {
|
||||
setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const core: LoginCore & { type: 'login' } = {
|
||||
type: 'login',
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
url: url || undefined,
|
||||
totp,
|
||||
};
|
||||
|
||||
// Build the Item. On edit we preserve id/created/tags/favorite/sections/
|
||||
// attachments/field_history from the existing item, but we EXPLICITLY
|
||||
// set trashed_at: undefined — never preserve stale trash state through
|
||||
// an edit (carry-forward from Slice 5 review M3).
|
||||
const item: Item = {
|
||||
id: existing?.id ?? '', // SW fills in for add_item.
|
||||
title,
|
||||
type: 'login',
|
||||
tags: existing?.tags ?? [],
|
||||
favorite: existing?.favorite ?? false,
|
||||
group: group || undefined,
|
||||
notes: notes || undefined,
|
||||
created: existing?.created ?? now,
|
||||
modified: now,
|
||||
trashed_at: undefined,
|
||||
core,
|
||||
sections: existing?.sections ?? [],
|
||||
attachments: existing?.attachments ?? [],
|
||||
field_history: existing?.field_history ?? {},
|
||||
};
|
||||
|
||||
setState({ loading: true, error: null });
|
||||
|
||||
let resp;
|
||||
if (mode === 'add') {
|
||||
resp = await sendMessage({ type: 'add_item', item });
|
||||
} else {
|
||||
if (!state.selectedId) {
|
||||
setState({ loading: false, error: 'Missing item id' });
|
||||
return;
|
||||
}
|
||||
resp = await sendMessage({ type: 'update_item', id: state.selectedId, item });
|
||||
}
|
||||
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else {
|
||||
navigate('list');
|
||||
}
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,10 @@ export function renderItemList(app: HTMLElement): void {
|
||||
setState({ searchQuery: searchInput.value, selectedIndex: 0 });
|
||||
});
|
||||
|
||||
document.getElementById('new-btn')?.addEventListener('click', () => navigate('add'));
|
||||
document.getElementById('new-btn')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showNewTypePicker(e.currentTarget as HTMLElement);
|
||||
});
|
||||
|
||||
document.getElementById('sync-btn')?.addEventListener('click', async () => {
|
||||
setState({ loading: true, error: null });
|
||||
@@ -215,3 +218,92 @@ function handleListKeydown(e: KeyboardEvent): void {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// New-item type picker popover
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const NEW_TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; disabled?: boolean; tooltip?: string }> = [
|
||||
{ type: 'login', icon: '🔑', label: 'login' },
|
||||
{ type: 'secure_note', icon: '📝', label: 'secure note' },
|
||||
{ type: 'identity', icon: '🪪', label: 'identity' },
|
||||
{ type: 'card', icon: '💳', label: 'card' },
|
||||
{ type: 'key', icon: '🗝', label: 'key' },
|
||||
{ type: 'totp', icon: '⏱', label: 'totp' },
|
||||
{ type: 'document', icon: '📄', label: 'document', disabled: true, tooltip: 'coming in γ — needs attachment upload' },
|
||||
];
|
||||
|
||||
function showNewTypePicker(anchor: HTMLElement): void {
|
||||
document.querySelectorAll('.new-type-picker').forEach((el) => el.remove());
|
||||
|
||||
const picker = document.createElement('div');
|
||||
picker.className = 'new-type-picker';
|
||||
Object.assign(picker.style, {
|
||||
position: 'absolute',
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
padding: '4px',
|
||||
minWidth: '160px',
|
||||
zIndex: '999999',
|
||||
fontSize: '12px',
|
||||
});
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
picker.style.top = `${rect.bottom + 4}px`;
|
||||
picker.style.left = `${rect.left}px`;
|
||||
|
||||
for (const opt of NEW_TYPE_OPTIONS) {
|
||||
const row = document.createElement('div');
|
||||
Object.assign(row.style, {
|
||||
padding: '6px 10px',
|
||||
cursor: opt.disabled ? 'not-allowed' : 'pointer',
|
||||
color: opt.disabled ? '#484f58' : '#c9d1d9',
|
||||
borderRadius: '4px',
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
});
|
||||
if (opt.tooltip) row.title = opt.tooltip;
|
||||
const iconSpan = document.createElement('span');
|
||||
Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', display: 'inline-block', textAlign: 'center' });
|
||||
iconSpan.textContent = opt.icon;
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.textContent = opt.label;
|
||||
row.appendChild(iconSpan);
|
||||
row.appendChild(labelSpan);
|
||||
if (!opt.disabled) {
|
||||
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
|
||||
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
|
||||
row.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
picker.remove();
|
||||
document.removeEventListener('click', closeOnOutside);
|
||||
document.removeEventListener('keydown', closeOnEsc);
|
||||
setState({ newType: opt.type });
|
||||
navigate('add');
|
||||
});
|
||||
}
|
||||
picker.appendChild(row);
|
||||
}
|
||||
|
||||
document.body.appendChild(picker);
|
||||
|
||||
const closeOnOutside = (ev: MouseEvent) => {
|
||||
if (!picker.contains(ev.target as Node)) {
|
||||
picker.remove();
|
||||
document.removeEventListener('click', closeOnOutside);
|
||||
document.removeEventListener('keydown', closeOnEsc);
|
||||
}
|
||||
};
|
||||
const closeOnEsc = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') {
|
||||
picker.remove();
|
||||
document.removeEventListener('click', closeOnOutside);
|
||||
document.removeEventListener('keydown', closeOnEsc);
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeOnOutside);
|
||||
document.addEventListener('keydown', closeOnEsc);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../popup', 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: 'card',
|
||||
}));
|
||||
const escapeHtml = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||
});
|
||||
|
||||
import { renderForm } from '../card';
|
||||
import { sendMessage } from '../../../popup';
|
||||
|
||||
describe('Card save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
|
||||
});
|
||||
|
||||
it('builds an Item with expiry as { month, year } and kind from select', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'Amex Gold';
|
||||
(document.getElementById('f-number') as HTMLInputElement).value = '378282246310005';
|
||||
(document.getElementById('f-holder') as HTMLInputElement).value = 'AARON LEE';
|
||||
(document.getElementById('f-expiry-month') as HTMLSelectElement).value = '08';
|
||||
(document.getElementById('f-expiry-year') as HTMLSelectElement).value = '2029';
|
||||
(document.getElementById('f-cvv') as HTMLInputElement).value = '1234';
|
||||
(document.getElementById('f-pin') as HTMLInputElement).value = '5678';
|
||||
(document.getElementById('f-kind') as HTMLSelectElement).value = 'credit';
|
||||
|
||||
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');
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.type).toBe('card');
|
||||
expect(msg.item.core).toMatchObject({
|
||||
type: 'card',
|
||||
number: '378282246310005',
|
||||
holder: 'AARON LEE',
|
||||
expiry: { month: 8, year: 2029 },
|
||||
cvv: '1234',
|
||||
pin: '5678',
|
||||
kind: 'credit',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits expiry entirely when month or year is empty', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'Loyalty card';
|
||||
(document.getElementById('f-kind') as HTMLSelectElement).value = 'loyalty';
|
||||
// expiry-month + expiry-year left empty.
|
||||
|
||||
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');
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.core.expiry).toBeUndefined();
|
||||
expect(msg.item.core.kind).toBe('loyalty');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../popup', 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: 'identity',
|
||||
}));
|
||||
const escapeHtml = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||
});
|
||||
|
||||
import { renderForm } from '../identity';
|
||||
import { sendMessage } from '../../../popup';
|
||||
|
||||
describe('Identity save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
|
||||
});
|
||||
|
||||
it('builds an Item with all populated fields and undefined for blanks', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'Aaron Lee · personal';
|
||||
(document.getElementById('f-full-name') as HTMLInputElement).value = 'Aaron Lee';
|
||||
(document.getElementById('f-email') as HTMLInputElement).value = 'aaron@example.com';
|
||||
(document.getElementById('f-phone') as HTMLInputElement).value = '+1 555 0100';
|
||||
(document.getElementById('f-address') as HTMLTextAreaElement).value = '1 Main St\nSpringfield';
|
||||
(document.getElementById('f-dob') as HTMLInputElement).value = '1985-05-23';
|
||||
|
||||
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');
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.type).toBe('identity');
|
||||
expect(msg.item.core).toEqual({
|
||||
type: 'identity',
|
||||
full_name: 'Aaron Lee',
|
||||
email: 'aaron@example.com',
|
||||
phone: '+1 555 0100',
|
||||
address: '1 Main St\nSpringfield',
|
||||
date_of_birth: '1985-05-23',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves empty fields out of core entirely', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'name only';
|
||||
(document.getElementById('f-full-name') as HTMLInputElement).value = 'Bob';
|
||||
// Other fields left blank.
|
||||
|
||||
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');
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.core.full_name).toBe('Bob');
|
||||
expect(msg.item.core.email).toBeUndefined();
|
||||
expect(msg.item.core.phone).toBeUndefined();
|
||||
expect(msg.item.core.address).toBeUndefined();
|
||||
expect(msg.item.core.date_of_birth).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../popup', 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: 'key',
|
||||
}));
|
||||
const escapeHtml = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||
});
|
||||
|
||||
import { renderForm } from '../key';
|
||||
import { sendMessage } from '../../../popup';
|
||||
|
||||
describe('Key save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
|
||||
});
|
||||
|
||||
it('requires key_material and emits all populated fields', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'github ssh';
|
||||
(document.getElementById('f-key-material') as HTMLTextAreaElement).value = '-----BEGIN OPENSSH PRIVATE KEY-----\nfoo\n-----END...';
|
||||
(document.getElementById('f-label') as HTMLInputElement).value = 'work laptop';
|
||||
(document.getElementById('f-public-key') as HTMLTextAreaElement).value = 'ssh-ed25519 AAAA...';
|
||||
(document.getElementById('f-algorithm') as HTMLInputElement).value = 'ed25519';
|
||||
|
||||
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');
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.type).toBe('key');
|
||||
expect(msg.item.core).toEqual({
|
||||
type: 'key',
|
||||
key_material: '-----BEGIN OPENSSH PRIVATE KEY-----\nfoo\n-----END...',
|
||||
label: 'work laptop',
|
||||
public_key: 'ssh-ed25519 AAAA...',
|
||||
algorithm: 'ed25519',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty key_material', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'no key';
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../popup', 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: 'secure_note',
|
||||
}));
|
||||
const escapeHtml = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||
});
|
||||
|
||||
import { renderForm } from '../secure-note';
|
||||
import { sendMessage } from '../../../popup';
|
||||
|
||||
describe('SecureNote save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
|
||||
});
|
||||
|
||||
it('builds an Item with type=secure_note and the body in core', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'My Secret Note';
|
||||
(document.getElementById('f-body') as HTMLTextAreaElement).value = 'hello\nworld';
|
||||
|
||||
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.title).toBe('My Secret Note');
|
||||
expect(msg.item.type).toBe('secure_note');
|
||||
expect(msg.item.core).toEqual({ type: 'secure_note', body: 'hello\nworld' });
|
||||
expect(msg.item.trashed_at).toBeUndefined();
|
||||
expect(msg.item.sections).toEqual([]);
|
||||
expect(msg.item.attachments).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../popup', 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: 'totp',
|
||||
}));
|
||||
const escapeHtml = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||
});
|
||||
|
||||
import { renderForm } from '../totp';
|
||||
import { sendMessage } from '../../../popup';
|
||||
import { base32Decode } from '../../../../shared/base32';
|
||||
|
||||
describe('Totp save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
|
||||
});
|
||||
|
||||
it('TOTP kind: secret round-trips via base32, defaults applied', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'GitHub';
|
||||
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
|
||||
(document.getElementById('f-issuer') as HTMLInputElement).value = 'GitHub';
|
||||
(document.getElementById('f-label') as HTMLInputElement).value = 'alice';
|
||||
|
||||
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');
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.type).toBe('totp');
|
||||
expect(msg.item.core).toMatchObject({
|
||||
type: 'totp',
|
||||
issuer: 'GitHub',
|
||||
label: 'alice',
|
||||
config: {
|
||||
secret: Array.from(base32Decode('JBSWY3DPEHPK3PXP')),
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: 'totp',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Steam kind: digits set to 5, kind set to steam', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'Steam';
|
||||
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
|
||||
(document.getElementById('kind-steam') as HTMLButtonElement).click();
|
||||
// After the click, the form re-renders; re-query the secret field and re-populate.
|
||||
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'Steam';
|
||||
|
||||
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');
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.core.config).toMatchObject({
|
||||
digits: 5,
|
||||
kind: 'steam',
|
||||
algorithm: 'sha1',
|
||||
period_seconds: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty secret', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'no secret';
|
||||
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();
|
||||
});
|
||||
});
|
||||
262
extension/src/popup/components/types/card.ts
Normal file
262
extension/src/popup/components/types/card.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/// Card: number / holder / expiry MonthYear / cvv / pin / kind.
|
||||
/// Detail view has a styled card-silhouette signature block.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry, CardKind } from '../../../shared/types';
|
||||
import {
|
||||
renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
|
||||
} from '../fields';
|
||||
|
||||
const CARD_KINDS: CardKind[] = ['credit', 'debit', 'gift', 'loyalty', 'other'];
|
||||
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
export function teardown(): void {
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
if (activeFormEscHandler) {
|
||||
document.removeEventListener('keydown', activeFormEscHandler);
|
||||
activeFormEscHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function brandFromNumber(num: string): string {
|
||||
if (/^3[47]/.test(num)) return 'AMEX';
|
||||
if (/^4/.test(num)) return 'VISA';
|
||||
if (/^5[1-5]/.test(num)) return 'MASTERCARD';
|
||||
if (/^6/.test(num)) return 'DISCOVER';
|
||||
return '';
|
||||
}
|
||||
|
||||
function maskedNumber(num: string): string {
|
||||
if (!num) return '';
|
||||
const last4 = num.slice(-4);
|
||||
const groups = num.length > 4 ? '•••• •••• •••• ' : '';
|
||||
return `${groups}${last4}`;
|
||||
}
|
||||
|
||||
function formatExpiry(e: { month: number; year: number } | undefined): string {
|
||||
if (!e) return '';
|
||||
const mm = String(e.month).padStart(2, '0');
|
||||
const yy = String(e.year).slice(-2);
|
||||
return `${mm}/${yy}`;
|
||||
}
|
||||
|
||||
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||
if (item.core.type !== 'card') return;
|
||||
const c = item.core;
|
||||
const number = c.number ?? '';
|
||||
const brand = brandFromNumber(number);
|
||||
const kindLabel = (c.kind ?? 'other').toUpperCase();
|
||||
const bandLabel = brand ? `${brand} · ${kindLabel}` : kindLabel;
|
||||
|
||||
const sigInner = `
|
||||
<div style="font-size:9px;letter-spacing:0.1em;color:#6e7681;margin-bottom:6px;">${escapeHtml(bandLabel)}</div>
|
||||
<div style="font-family:monospace;font-size:14px;letter-spacing:0.08em;color:#c9d1d9;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center;" data-field-id="card-number" data-revealed="false" data-field-value="${escapeHtml(number)}" data-field-multiline="false">
|
||||
<span data-field-role="value">${escapeHtml(maskedNumber(number))}</span>
|
||||
${number ? '<button type="button" data-field-action="reveal" style="font-size:10px;color:#8b949e;cursor:pointer;font-family:system-ui,sans-serif;letter-spacing:0;background:transparent;border:0;">show</button>' : ''}
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-end;">
|
||||
<div>
|
||||
<div style="font-size:9px;color:#6e7681;letter-spacing:0.08em;">HOLDER</div>
|
||||
<div style="font-size:11px;color:#c9d1d9;">${escapeHtml(c.holder ?? '')}</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:9px;color:#6e7681;letter-spacing:0.08em;">EXPIRES</div>
|
||||
<div style="font-family:monospace;font-size:11px;color:#c9d1d9;">${escapeHtml(formatExpiry(c.expiry))}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="detail-title" style="margin-bottom:8px;">${escapeHtml(item.title)}</div>
|
||||
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
|
||||
</div>
|
||||
${c.cvv ? renderConcealedRow({ id: 'card-cvv', label: 'cvv', value: c.cvv, monospace: true }) : ''}
|
||||
${c.pin ? renderConcealedRow({ id: 'card-pin', label: 'pin', value: c.pin, monospace: true }) : ''}
|
||||
<div class="form-actions" style="margin-top:14px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn danger" id="trash-btn">trash</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// The card-number reveal lives inside the signature block, so wireFieldHandlers
|
||||
// picks it up alongside the cvv/pin rows.
|
||||
wireFieldHandlers(app);
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
||||
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
||||
document.getElementById('trash-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm(`Move "${item.title}" to trash?`)) return;
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
});
|
||||
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Escape': teardown(); navigate('list'); break;
|
||||
case 'e': teardown(); navigate('edit'); break;
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
if (confirm(`Move "${item.title}" to trash?`)) {
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
activeKeyHandler = handler;
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const title = existing?.title ?? '';
|
||||
const c = (existing?.core.type === 'card') ? existing.core : null;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => {
|
||||
const m = String(i + 1).padStart(2, '0');
|
||||
const sel = c?.expiry?.month === i + 1 ? 'selected' : '';
|
||||
return `<option value="${m}" ${sel}>${m}</option>`;
|
||||
}).join('');
|
||||
const yearOptions = Array.from({ length: 51 }, (_, i) => {
|
||||
const y = currentYear - 25 + i;
|
||||
const sel = c?.expiry?.year === y ? 'selected' : '';
|
||||
return `<option value="${y}" ${sel}>${y}</option>`;
|
||||
}).join('');
|
||||
const kindOptions = CARD_KINDS.map((k) => {
|
||||
const sel = (c?.kind ?? 'credit') === k ? 'selected' : '';
|
||||
return `<option value="${k}" ${sel}>${k}</option>`;
|
||||
}).join('');
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new card' : 'edit card'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title *</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Amex Gold"></div>
|
||||
<div class="form-group"><label class="label" for="f-number">number</label>
|
||||
<input id="f-number" type="text" inputmode="numeric" value="${escapeHtml(c?.number ?? '')}" placeholder="378282246310005"></div>
|
||||
<div class="form-group"><label class="label" for="f-holder">holder</label>
|
||||
<input id="f-holder" type="text" value="${escapeHtml(c?.holder ?? '')}" placeholder="AARON LEE"></div>
|
||||
<div class="form-group"><label class="label">expiry</label>
|
||||
<div class="inline-row">
|
||||
<select id="f-expiry-month"><option value="">mm</option>${monthOptions}</select>
|
||||
<select id="f-expiry-year"><option value="">yyyy</option>${yearOptions}</select>
|
||||
</div></div>
|
||||
<div class="form-group"><label class="label" for="f-cvv">cvv</label>
|
||||
<input id="f-cvv" type="password" inputmode="numeric" maxlength="4" value="${escapeHtml(c?.cvv ?? '')}"></div>
|
||||
<div class="form-group"><label class="label" for="f-pin">pin</label>
|
||||
<input id="f-pin" type="password" inputmode="numeric" maxlength="8" value="${escapeHtml(c?.pin ?? '')}"></div>
|
||||
<div class="form-group"><label class="label" for="f-kind">kind</label>
|
||||
<select id="f-kind">${kindOptions}</select></div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveCard(mode, existing);
|
||||
});
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
}
|
||||
};
|
||||
activeFormEscHandler = escHandler;
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||
}
|
||||
|
||||
async function saveCard(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||
|
||||
const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value.trim();
|
||||
const number = get('f-number');
|
||||
const holder = get('f-holder');
|
||||
const expMonth = get('f-expiry-month');
|
||||
const expYear = get('f-expiry-year');
|
||||
const cvv = get('f-cvv');
|
||||
const pin = get('f-pin');
|
||||
const kindRaw = get('f-kind');
|
||||
const kind: CardKind = (CARD_KINDS as string[]).includes(kindRaw) ? (kindRaw as CardKind) : 'credit';
|
||||
|
||||
const expiry = (expMonth && expYear)
|
||||
? { month: Number(expMonth), year: Number(expYear) }
|
||||
: undefined;
|
||||
|
||||
const core = {
|
||||
type: 'card' as const,
|
||||
number: number || undefined,
|
||||
holder: holder || undefined,
|
||||
expiry,
|
||||
cvv: cvv || undefined,
|
||||
pin: pin || undefined,
|
||||
kind,
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const item: Item = {
|
||||
id: existing?.id ?? '',
|
||||
title, type: 'card',
|
||||
tags: existing?.tags ?? [],
|
||||
favorite: existing?.favorite ?? false,
|
||||
group: existing?.group, notes: existing?.notes,
|
||||
created: existing?.created ?? now,
|
||||
modified: now, trashed_at: undefined,
|
||||
core,
|
||||
sections: existing?.sections ?? [],
|
||||
attachments: existing?.attachments ?? [],
|
||||
field_history: existing?.field_history ?? {},
|
||||
};
|
||||
|
||||
setState({ loading: true, error: null });
|
||||
const resp = mode === 'add'
|
||||
? await sendMessage({ type: 'add_item', item })
|
||||
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
204
extension/src/popup/components/types/identity.ts
Normal file
204
extension/src/popup/components/types/identity.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/// Identity: full_name, address (multiline), phone, email, date_of_birth.
|
||||
/// Detail view shows a "profile card" signature block + plain rows.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry } from '../../../shared/types';
|
||||
import {
|
||||
renderRow, renderSignatureBlock, wireFieldHandlers,
|
||||
} from '../fields';
|
||||
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
export function teardown(): void {
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
if (activeFormEscHandler) {
|
||||
document.removeEventListener('keydown', activeFormEscHandler);
|
||||
activeFormEscHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function initials(name: string | undefined): string {
|
||||
if (!name) return '?';
|
||||
const parts = name.trim().split(/\s+/).slice(0, 2);
|
||||
return parts.map((p) => p.charAt(0).toUpperCase()).join('') || '?';
|
||||
}
|
||||
|
||||
function formatDate(iso: string | undefined): string {
|
||||
if (!iso) return '';
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
if (!m) return iso;
|
||||
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||
if (item.core.type !== 'identity') return;
|
||||
const c = item.core;
|
||||
const sigInner = `
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="width:36px;height:36px;border-radius:50%;background:#d29922;color:#0d1117;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;">${escapeHtml(initials(c.full_name))}</div>
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(c.full_name ?? item.title)}</div>
|
||||
${c.email ? `<div style="font-size:11px;color:#8b949e;">${escapeHtml(c.email)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="margin-bottom:12px;">
|
||||
${renderSignatureBlock({ accent: 'amber', children: sigInner })}
|
||||
</div>
|
||||
${c.phone ? renderRow({ label: 'phone', value: c.phone, copyable: true }) : ''}
|
||||
${c.email ? renderRow({ label: 'email', value: c.email, copyable: true }) : ''}
|
||||
${c.address ? renderRow({ label: 'address', value: c.address, multiline: true }) : ''}
|
||||
${c.date_of_birth ? renderRow({ label: 'born', value: formatDate(c.date_of_birth) }) : ''}
|
||||
<div class="form-actions" style="margin-top:14px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn danger" id="trash-btn">trash</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wireFieldHandlers(app);
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
||||
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
||||
document.getElementById('trash-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm(`Move "${item.title}" to trash?`)) return;
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
});
|
||||
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Escape': teardown(); navigate('list'); break;
|
||||
case 'e': teardown(); navigate('edit'); break;
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
if (confirm(`Move "${item.title}" to trash?`)) {
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
activeKeyHandler = handler;
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const title = existing?.title ?? '';
|
||||
const c = (existing?.core.type === 'identity') ? existing.core : null;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new identity' : 'edit identity'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title *</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Aaron Lee · personal"></div>
|
||||
<div class="form-group"><label class="label" for="f-full-name">full name</label>
|
||||
<input id="f-full-name" type="text" value="${escapeHtml(c?.full_name ?? '')}" placeholder="Aaron Lee"></div>
|
||||
<div class="form-group"><label class="label" for="f-email">email</label>
|
||||
<input id="f-email" type="email" value="${escapeHtml(c?.email ?? '')}" placeholder="aaron@example.com"></div>
|
||||
<div class="form-group"><label class="label" for="f-phone">phone</label>
|
||||
<input id="f-phone" type="tel" value="${escapeHtml(c?.phone ?? '')}" placeholder="+1 555 0100"></div>
|
||||
<div class="form-group"><label class="label" for="f-address">address</label>
|
||||
<textarea id="f-address" rows="3" placeholder="street, city, postcode...">${escapeHtml(c?.address ?? '')}</textarea></div>
|
||||
<div class="form-group"><label class="label" for="f-dob">date of birth</label>
|
||||
<input id="f-dob" type="date" value="${escapeHtml(c?.date_of_birth ?? '')}"></div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveIdentity(mode, existing);
|
||||
});
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
}
|
||||
};
|
||||
activeFormEscHandler = escHandler;
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||
}
|
||||
|
||||
async function saveIdentity(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||
|
||||
const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement).value.trim();
|
||||
|
||||
const core = {
|
||||
type: 'identity' as const,
|
||||
full_name: get('f-full-name') || undefined,
|
||||
email: get('f-email') || undefined,
|
||||
phone: get('f-phone') || undefined,
|
||||
address: get('f-address') || undefined,
|
||||
date_of_birth: get('f-dob') || undefined,
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const item: Item = {
|
||||
id: existing?.id ?? '',
|
||||
title, type: 'identity',
|
||||
tags: existing?.tags ?? [],
|
||||
favorite: existing?.favorite ?? false,
|
||||
group: existing?.group, notes: existing?.notes,
|
||||
created: existing?.created ?? now,
|
||||
modified: now, trashed_at: undefined,
|
||||
core,
|
||||
sections: existing?.sections ?? [],
|
||||
attachments: existing?.attachments ?? [],
|
||||
field_history: existing?.field_history ?? {},
|
||||
};
|
||||
|
||||
setState({ loading: true, error: null });
|
||||
const resp = mode === 'add'
|
||||
? await sendMessage({ type: 'add_item', item })
|
||||
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
205
extension/src/popup/components/types/key.ts
Normal file
205
extension/src/popup/components/types/key.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/// Key: key_material (required, concealed multiline) + label/algorithm/public_key.
|
||||
/// Form's key_material textarea uses CSS text-security to mask characters
|
||||
/// since <textarea type="password"> isn't a thing.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry } from '../../../shared/types';
|
||||
import {
|
||||
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
|
||||
} from '../fields';
|
||||
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
export function teardown(): void {
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
if (activeFormEscHandler) {
|
||||
document.removeEventListener('keydown', activeFormEscHandler);
|
||||
activeFormEscHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||
if (item.core.type !== 'key') return;
|
||||
const c = item.core;
|
||||
|
||||
const sigInner = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(item.title)}</div>
|
||||
${c.algorithm ? `<div style="font-size:10px;color:#8b949e;font-family:monospace;">${escapeHtml(c.algorithm)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="margin-bottom:12px;">
|
||||
${renderSignatureBlock({ accent: 'green', children: sigInner })}
|
||||
</div>
|
||||
${renderConcealedRow({ id: 'key-material', label: 'private', value: c.key_material, multiline: true, monospace: true })}
|
||||
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
|
||||
${c.algorithm ? renderRow({ label: 'algorithm', value: c.algorithm }) : ''}
|
||||
${c.public_key ? renderRow({ label: 'public', value: c.public_key, multiline: true, monospace: true, copyable: true }) : ''}
|
||||
<div class="form-actions" style="margin-top:14px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn danger" id="trash-btn">trash</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wireFieldHandlers(app);
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
||||
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
||||
document.getElementById('trash-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm(`Move "${item.title}" to trash?`)) return;
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
});
|
||||
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Escape': teardown(); navigate('list'); break;
|
||||
case 'e': teardown(); navigate('edit'); break;
|
||||
case 'c':
|
||||
e.preventDefault();
|
||||
try { await navigator.clipboard.writeText(c.key_material); } catch { /* swallow */ }
|
||||
break;
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
if (confirm(`Move "${item.title}" to trash?`)) {
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
activeKeyHandler = handler;
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const title = existing?.title ?? '';
|
||||
const c = (existing?.core.type === 'key') ? existing.core : null;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new key' : 'edit key'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title *</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="github ssh"></div>
|
||||
<div class="form-group"><label class="label" for="f-key-material">key material *</label>
|
||||
<div style="position:relative;">
|
||||
<textarea id="f-key-material" rows="8" style="font-family:monospace;-webkit-text-security:disc;" placeholder="paste key here">${escapeHtml(c?.key_material ?? '')}</textarea>
|
||||
<button type="button" id="key-show-btn" class="btn" style="position:absolute;right:6px;top:6px;font-size:10px;padding:2px 8px;">show</button>
|
||||
</div></div>
|
||||
<div class="form-group"><label class="label" for="f-label">label</label>
|
||||
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="work laptop"></div>
|
||||
<div class="form-group"><label class="label" for="f-algorithm">algorithm</label>
|
||||
<input id="f-algorithm" type="text" value="${escapeHtml(c?.algorithm ?? '')}" placeholder="ed25519"></div>
|
||||
<div class="form-group"><label class="label" for="f-public-key">public key</label>
|
||||
<textarea id="f-public-key" rows="4" style="font-family:monospace;" placeholder="ssh-ed25519 AAAA...">${escapeHtml(c?.public_key ?? '')}</textarea></div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show/hide toggle for the key_material textarea.
|
||||
let revealed = false;
|
||||
document.getElementById('key-show-btn')?.addEventListener('click', () => {
|
||||
revealed = !revealed;
|
||||
const ta = document.getElementById('f-key-material') as HTMLTextAreaElement;
|
||||
(ta.style as CSSStyleDeclaration & { webkitTextSecurity?: string }).webkitTextSecurity = revealed ? 'none' : 'disc';
|
||||
(document.getElementById('key-show-btn') as HTMLButtonElement).textContent = revealed ? 'hide' : 'show';
|
||||
});
|
||||
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveKey(mode, existing);
|
||||
});
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
}
|
||||
};
|
||||
activeFormEscHandler = escHandler;
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||
}
|
||||
|
||||
async function saveKey(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||
|
||||
const keyMaterial = (document.getElementById('f-key-material') as HTMLTextAreaElement).value;
|
||||
if (!keyMaterial) { setState({ error: 'Key material is required' }); return; }
|
||||
|
||||
const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement).value.trim();
|
||||
|
||||
const core = {
|
||||
type: 'key' as const,
|
||||
key_material: keyMaterial,
|
||||
label: get('f-label') || undefined,
|
||||
public_key: get('f-public-key') || undefined,
|
||||
algorithm: get('f-algorithm') || undefined,
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const item: Item = {
|
||||
id: existing?.id ?? '',
|
||||
title, type: 'key',
|
||||
tags: existing?.tags ?? [],
|
||||
favorite: existing?.favorite ?? false,
|
||||
group: existing?.group, notes: existing?.notes,
|
||||
created: existing?.created ?? now,
|
||||
modified: now, trashed_at: undefined,
|
||||
core,
|
||||
sections: existing?.sections ?? [],
|
||||
attachments: existing?.attachments ?? [],
|
||||
field_history: existing?.field_history ?? {},
|
||||
};
|
||||
|
||||
setState({ loading: true, error: null });
|
||||
const resp = mode === 'add'
|
||||
? await sendMessage({ type: 'add_item', item })
|
||||
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
360
extension/src/popup/components/types/login.ts
Normal file
360
extension/src/popup/components/types/login.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/// Login type detail + form. Reference implementation for the shared
|
||||
/// field helpers introduced in Slice 2.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import type { Item, ItemId, LoginCore, ManifestEntry, TotpConfig } from '../../../shared/types';
|
||||
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
|
||||
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||
import {
|
||||
renderRow,
|
||||
renderConcealedRow,
|
||||
renderSignatureBlock,
|
||||
wireFieldHandlers,
|
||||
} from '../fields';
|
||||
|
||||
/// Called by the dispatcher before each render. Stops any in-flight
|
||||
/// tickers / intervals / listeners the previous view may have attached.
|
||||
export function teardown(): void {
|
||||
stopTotpTicker();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
if (activeFormEscHandler) {
|
||||
document.removeEventListener('keydown', activeFormEscHandler);
|
||||
activeFormEscHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Detail view
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||
if (item.core.type !== 'login') return;
|
||||
const core = item.core as LoginCore & { type: 'login' };
|
||||
const password = core.password ?? '';
|
||||
const username = core.username ?? '';
|
||||
const url = core.url ?? '';
|
||||
const hasTotp = core.totp !== undefined;
|
||||
|
||||
const sigInner = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(item.title)}</div>
|
||||
${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="font-size:11px;color:#58a6ff;">open ↗</a>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="margin-bottom:12px;">
|
||||
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
|
||||
</div>
|
||||
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
|
||||
${renderConcealedRow({ id: 'login-password', label: 'password', value: password })}
|
||||
${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
|
||||
${hasTotp ? `
|
||||
<div class="field-row">
|
||||
<span class="field-row__label">totp</span>
|
||||
<span class="field-row__value monospace" id="totp-code">…</span>
|
||||
<span class="field-row__actions"><span id="totp-countdown" style="font-variant-numeric:tabular-nums;">…</span></span>
|
||||
</div>
|
||||
` : ''}
|
||||
${item.notes ? renderRow({ label: 'notes', value: item.notes, multiline: true }) : ''}
|
||||
<div class="form-actions" style="margin-top:14px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn" id="fill-btn">autofill</button>
|
||||
<button class="btn danger" id="trash-btn">trash</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wireFieldHandlers(app);
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
teardown();
|
||||
navigate('list');
|
||||
});
|
||||
document.getElementById('edit-btn')?.addEventListener('click', () => {
|
||||
teardown();
|
||||
navigate('edit');
|
||||
});
|
||||
document.getElementById('trash-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm(`Move "${item.title}" to trash?`)) return;
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
});
|
||||
document.getElementById('fill-btn')?.addEventListener('click', async () => {
|
||||
const { capturedTabId, capturedUrl } = getState();
|
||||
if (capturedTabId === null) { setState({ error: 'No active tab captured' }); return; }
|
||||
const resp = await sendMessage({
|
||||
type: 'fill_credentials', id: item.id, capturedTabId, capturedUrl,
|
||||
});
|
||||
if (!resp.ok) setState({ error: resp.error });
|
||||
else { teardown(); window.close(); }
|
||||
});
|
||||
|
||||
if (hasTotp) startTotpTicker(item.id);
|
||||
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
// Bail if the user is typing in an editable field — don't steal printable keystrokes.
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
teardown();
|
||||
navigate('list');
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
if (username) {
|
||||
try { await navigator.clipboard.writeText(username); } catch { /* no-op */ }
|
||||
}
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
try { await navigator.clipboard.writeText(password); } catch { /* no-op */ }
|
||||
break;
|
||||
|
||||
case 't':
|
||||
if (hasTotp) {
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
const code = codeEl?.textContent?.trim();
|
||||
if (code && code !== '…') {
|
||||
try { await navigator.clipboard.writeText(code); } catch { /* no-op */ }
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'f': {
|
||||
const { capturedTabId, capturedUrl } = getState();
|
||||
if (capturedTabId === null) { setState({ error: 'No active tab captured' }); break; }
|
||||
const resp = await sendMessage({
|
||||
type: 'fill_credentials', id: item.id, capturedTabId, capturedUrl,
|
||||
});
|
||||
if (!resp.ok) setState({ error: resp.error });
|
||||
else { teardown(); window.close(); }
|
||||
break;
|
||||
}
|
||||
|
||||
case 'e':
|
||||
teardown();
|
||||
navigate('edit');
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
if (confirm(`Move "${item.title}" to trash?`)) {
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
activeKeyHandler = handler;
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// TOTP ticker
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
let totpTickerId: ReturnType<typeof setInterval> | null = null;
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
function stopTotpTicker(): void {
|
||||
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
|
||||
}
|
||||
function startTotpTicker(id: ItemId): void {
|
||||
stopTotpTicker();
|
||||
const tick = async () => {
|
||||
const r = await sendMessage({ type: 'get_totp', id });
|
||||
if (!r.ok) return;
|
||||
const { code, expires_at } = r.data as { code: string; expires_at: number };
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
const cdEl = document.getElementById('totp-countdown');
|
||||
if (codeEl) codeEl.textContent = code;
|
||||
if (cdEl) cdEl.textContent = `${Math.max(0, expires_at - Math.floor(Date.now() / 1000))}s`;
|
||||
};
|
||||
void tick();
|
||||
totpTickerId = setInterval(() => void tick(), 1000);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Form (add / edit)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const existingCore = (existing?.core.type === 'login')
|
||||
? (existing.core as LoginCore & { type: 'login' })
|
||||
: null;
|
||||
|
||||
const title = existing?.title ?? '';
|
||||
const url = existingCore?.url ?? '';
|
||||
const username = existingCore?.username ?? '';
|
||||
const password = existingCore?.password ?? '';
|
||||
const totpStr = existingCore?.totp ? base32Encode(new Uint8Array(existingCore.totp.secret)) : '';
|
||||
const group = existing?.group ?? '';
|
||||
const notes = existing?.notes ?? '';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title *</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-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="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button class="btn" id="gen-btn" title="generate">gen</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>
|
||||
<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-actions">
|
||||
<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>
|
||||
`;
|
||||
|
||||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
||||
const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { password: string };
|
||||
const pw = document.getElementById('f-password') as HTMLInputElement;
|
||||
pw.value = data.password;
|
||||
pw.type = 'text';
|
||||
} else setState({ error: resp.error });
|
||||
});
|
||||
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveLogin(mode, existing);
|
||||
});
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
}
|
||||
};
|
||||
activeFormEscHandler = escHandler;
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||
}
|
||||
|
||||
function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } {
|
||||
if (!raw) return { ok: true, value: '' };
|
||||
const trimmed = raw.trim();
|
||||
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
try {
|
||||
const u = new URL(candidate);
|
||||
if (!u.host) return { ok: false, error: 'URL must include a host (e.g. https://example.com)' };
|
||||
return { ok: true, value: u.toString() };
|
||||
} catch {
|
||||
return { ok: false, error: 'URL is not valid — try something like https://example.com' };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
||||
const username = (document.getElementById('f-username') as HTMLInputElement).value.trim();
|
||||
const password = (document.getElementById('f-password') as HTMLInputElement).value;
|
||||
const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
|
||||
const group = (document.getElementById('f-group') as HTMLInputElement).value.trim();
|
||||
const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value;
|
||||
|
||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||
|
||||
const urlResult = normalizeUrl(rawUrl);
|
||||
if (!urlResult.ok) { setState({ error: urlResult.error }); return; }
|
||||
const url = urlResult.value;
|
||||
|
||||
let totp: TotpConfig | undefined;
|
||||
if (totpStr) {
|
||||
try {
|
||||
const bytes = base32Decode(totpStr);
|
||||
totp = {
|
||||
secret: Array.from(bytes),
|
||||
algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp',
|
||||
};
|
||||
} catch (err) {
|
||||
setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const core: LoginCore & { type: 'login' } = {
|
||||
type: 'login',
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
url: url || undefined,
|
||||
totp,
|
||||
};
|
||||
|
||||
const item: Item = {
|
||||
id: existing?.id ?? '',
|
||||
title, type: 'login',
|
||||
tags: existing?.tags ?? [],
|
||||
favorite: existing?.favorite ?? false,
|
||||
group: group || undefined,
|
||||
notes: notes || undefined,
|
||||
created: existing?.created ?? now,
|
||||
modified: now,
|
||||
trashed_at: undefined,
|
||||
core,
|
||||
sections: existing?.sections ?? [],
|
||||
attachments: existing?.attachments ?? [],
|
||||
field_history: existing?.field_history ?? {},
|
||||
};
|
||||
|
||||
setState({ loading: true, error: null });
|
||||
|
||||
const resp = mode === 'add'
|
||||
? await sendMessage({ type: 'add_item', item })
|
||||
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
|
||||
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
166
extension/src/popup/components/types/secure-note.ts
Normal file
166
extension/src/popup/components/types/secure-note.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/// SecureNote: a single multiline body field. Concealed by default in the
|
||||
/// detail view; the form is just a big <textarea>.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry } from '../../../shared/types';
|
||||
import {
|
||||
renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
|
||||
} from '../fields';
|
||||
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
export function teardown(): void {
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
if (activeFormEscHandler) {
|
||||
document.removeEventListener('keydown', activeFormEscHandler);
|
||||
activeFormEscHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||
if (item.core.type !== 'secure_note') return;
|
||||
const body = item.core.body ?? '';
|
||||
|
||||
const sigInner = `
|
||||
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(item.title)}</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="margin-bottom:12px;">
|
||||
${renderSignatureBlock({ accent: 'green', children: sigInner })}
|
||||
</div>
|
||||
${renderConcealedRow({ id: 'note-body', label: 'body', value: body, multiline: true })}
|
||||
<div class="form-actions" style="margin-top:14px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn danger" id="trash-btn">trash</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wireFieldHandlers(app);
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
||||
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
||||
document.getElementById('trash-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm(`Move "${item.title}" to trash?`)) return;
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
});
|
||||
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Escape': teardown(); navigate('list'); break;
|
||||
case 'e': teardown(); navigate('edit'); break;
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
if (confirm(`Move "${item.title}" to trash?`)) {
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
activeKeyHandler = handler;
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const title = existing?.title ?? '';
|
||||
const body = (existing?.core.type === 'secure_note') ? existing.core.body ?? '' : '';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new secure note' : 'edit secure note'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title *</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="My recovery codes"></div>
|
||||
<div class="form-group"><label class="label" for="f-body">body</label>
|
||||
<textarea id="f-body" rows="10" placeholder="paste secrets here">${escapeHtml(body)}</textarea></div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveSecureNote(mode, existing);
|
||||
});
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
}
|
||||
};
|
||||
activeFormEscHandler = escHandler;
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||
}
|
||||
|
||||
async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
const body = (document.getElementById('f-body') as HTMLTextAreaElement).value;
|
||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const item: Item = {
|
||||
id: existing?.id ?? '',
|
||||
title, type: 'secure_note',
|
||||
tags: existing?.tags ?? [],
|
||||
favorite: existing?.favorite ?? false,
|
||||
group: existing?.group,
|
||||
notes: existing?.notes,
|
||||
created: existing?.created ?? now,
|
||||
modified: now,
|
||||
trashed_at: undefined,
|
||||
core: { type: 'secure_note', body },
|
||||
sections: existing?.sections ?? [],
|
||||
attachments: existing?.attachments ?? [],
|
||||
field_history: existing?.field_history ?? {},
|
||||
};
|
||||
|
||||
setState({ loading: true, error: null });
|
||||
const resp = mode === 'add'
|
||||
? await sendMessage({ type: 'add_item', item })
|
||||
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
337
extension/src/popup/components/types/totp.ts
Normal file
337
extension/src/popup/components/types/totp.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/// Totp standalone item type. Detail view shows the rotating code in a
|
||||
/// signature block with a thin SVG countdown ring; form has a kind toggle
|
||||
/// (TOTP vs Steam Guard) and a single secret input.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry, TotpKind } from '../../../shared/types';
|
||||
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||
import {
|
||||
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
|
||||
} from '../fields';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Module-scope lifecycle state
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
let totpTickerId: ReturnType<typeof setInterval> | null = null;
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
function stopTotpTicker(): void {
|
||||
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
|
||||
}
|
||||
|
||||
/// Called by the dispatcher before each render. Stops the countdown ticker
|
||||
/// AND removes the detail-view's keyboard handler so they don't leak.
|
||||
export function teardown(): void {
|
||||
stopTotpTicker();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
if (activeFormEscHandler) {
|
||||
document.removeEventListener('keydown', activeFormEscHandler);
|
||||
activeFormEscHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Detail view
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||
if (item.core.type !== 'totp') return;
|
||||
const c = item.core;
|
||||
const secretB32 = base32Encode(new Uint8Array(c.config.secret));
|
||||
const isSteam = c.config.kind === 'steam';
|
||||
|
||||
const headerLine = c.issuer
|
||||
? `${escapeHtml(c.issuer)}${c.label ? ` · ${escapeHtml(c.label)}` : ''}`
|
||||
: escapeHtml(item.title);
|
||||
|
||||
// Countdown ring SVG. Stroke-dashoffset animates per tick (CSS transition
|
||||
// gives the smooth sweep between seconds).
|
||||
const ringSvg = `
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" style="display:block;">
|
||||
<circle cx="16" cy="16" r="14" stroke="#30363d" stroke-width="2" fill="none"/>
|
||||
<circle id="totp-ring-arc" cx="16" cy="16" r="14" stroke="#58a6ff" stroke-width="2" fill="none"
|
||||
stroke-linecap="round" stroke-dasharray="87.96"
|
||||
transform="rotate(-90 16 16)" style="transition:stroke-dashoffset 1s linear;"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const sigInner = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div style="font-size:11px;color:#8b949e;letter-spacing:0.04em;">${headerLine}</div>
|
||||
<div id="totp-code" style="font-family:monospace;font-size:28px;letter-spacing:0.12em;color:#c9d1d9;margin-top:4px;">${isSteam ? '·····' : '······'}</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
||||
${ringSvg}
|
||||
<span id="totp-countdown" style="font-size:10px;color:#8b949e;font-variant-numeric:tabular-nums;">…</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="detail-title" style="margin-bottom:8px;">${escapeHtml(item.title)}</div>
|
||||
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
|
||||
</div>
|
||||
${c.issuer ? renderRow({ label: 'issuer', value: c.issuer }) : ''}
|
||||
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
|
||||
${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })}
|
||||
${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })}
|
||||
<div class="form-actions" style="margin-top:14px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn danger" id="trash-btn">trash</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wireFieldHandlers(app);
|
||||
|
||||
// Start the ticker — re-fetches code + countdown every second from the SW.
|
||||
startTotpTicker(item.id, c.config.period_seconds || 30);
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
teardown();
|
||||
navigate('list');
|
||||
});
|
||||
document.getElementById('edit-btn')?.addEventListener('click', () => {
|
||||
teardown();
|
||||
navigate('edit');
|
||||
});
|
||||
document.getElementById('trash-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm(`Move "${item.title}" to trash?`)) return;
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
});
|
||||
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
// Don't steal printable keystrokes from editable fields.
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Escape': teardown(); navigate('list'); break;
|
||||
case 'e': teardown(); navigate('edit'); break;
|
||||
case 't': {
|
||||
// Copy the currently displayed rotating code.
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
const code = codeEl?.textContent?.trim();
|
||||
if (code && code !== '·····' && code !== '······') {
|
||||
try { await navigator.clipboard.writeText(code); } catch { /* swallow */ }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
if (confirm(`Move "${item.title}" to trash?`)) {
|
||||
teardown();
|
||||
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
activeKeyHandler = handler;
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Countdown ticker
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function startTotpTicker(id: ItemId, period: number): void {
|
||||
stopTotpTicker();
|
||||
const circumference = 2 * Math.PI * 14;
|
||||
const tick = async () => {
|
||||
const r = await sendMessage({ type: 'get_totp', id });
|
||||
if (!r.ok) return;
|
||||
const { code, expires_at } = r.data as { code: string; expires_at: number };
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
const cdEl = document.getElementById('totp-countdown');
|
||||
const ring = document.getElementById('totp-ring-arc') as SVGCircleElement | null;
|
||||
if (codeEl) codeEl.textContent = code;
|
||||
const remaining = Math.max(0, expires_at - Math.floor(Date.now() / 1000));
|
||||
if (cdEl) cdEl.textContent = `${remaining}s`;
|
||||
if (ring) {
|
||||
const offset = circumference * (1 - remaining / period);
|
||||
ring.style.strokeDashoffset = String(offset);
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
totpTickerId = setInterval(() => void tick(), 1000);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Form (add / edit) with TOTP/Steam kind toggle
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
let formKind: TotpKind = 'totp';
|
||||
|
||||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const title = existing?.title ?? '';
|
||||
const c = (existing?.core.type === 'totp') ? existing.core : null;
|
||||
formKind = c?.config.kind === 'steam' ? 'steam' : 'totp';
|
||||
const secretB32 = c?.config.secret ? base32Encode(new Uint8Array(c.config.secret)) : '';
|
||||
|
||||
const renderInner = (): string => `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title *</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
|
||||
<div class="form-group"><label class="label">kind</label>
|
||||
<div class="inline-row">
|
||||
<button type="button" id="kind-totp" class="btn ${formKind === 'totp' ? 'btn-primary' : ''}" style="flex:1;">TOTP</button>
|
||||
<button type="button" id="kind-steam" class="btn ${formKind === 'steam' ? 'btn-primary' : ''}" style="flex:1;">Steam Guard</button>
|
||||
</div>
|
||||
<p class="muted" style="font-size:11px;margin-top:4px;" id="kind-blurb">${formKind === 'steam' ? 'Steam Mobile Authenticator (5-char alphanumeric)' : 'Standard time-based codes (6 digits)'}</p>
|
||||
</div>
|
||||
<div class="form-group"><label class="label" for="f-secret">secret (base32) *</label>
|
||||
<input id="f-secret" type="text" value="${escapeHtml(secretB32)}" placeholder="JBSWY3DPEHPK3PXP"></div>
|
||||
<div class="form-group"><label class="label" for="f-issuer">issuer</label>
|
||||
<input id="f-issuer" type="text" value="${escapeHtml(c?.issuer ?? '')}" placeholder="GitHub"></div>
|
||||
<div class="form-group"><label class="label" for="f-label">label</label>
|
||||
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="alice@github.com"></div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = renderInner();
|
||||
|
||||
// In-place re-render on kind toggle. Preserves current input values so
|
||||
// the user doesn't lose what they've typed.
|
||||
const reRender = (): void => {
|
||||
const titleVal = (document.getElementById('f-title') as HTMLInputElement).value;
|
||||
const secretVal = (document.getElementById('f-secret') as HTMLInputElement).value;
|
||||
const issuerVal = (document.getElementById('f-issuer') as HTMLInputElement).value;
|
||||
const labelVal = (document.getElementById('f-label') as HTMLInputElement).value;
|
||||
app.innerHTML = renderInner();
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = titleVal;
|
||||
(document.getElementById('f-secret') as HTMLInputElement).value = secretVal;
|
||||
(document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal;
|
||||
(document.getElementById('f-label') as HTMLInputElement).value = labelVal;
|
||||
wireKindToggle();
|
||||
wireFormButtons(mode, existing);
|
||||
};
|
||||
|
||||
const wireKindToggle = (): void => {
|
||||
document.getElementById('kind-totp')?.addEventListener('click', () => {
|
||||
formKind = 'totp';
|
||||
reRender();
|
||||
});
|
||||
document.getElementById('kind-steam')?.addEventListener('click', () => {
|
||||
formKind = 'steam';
|
||||
reRender();
|
||||
});
|
||||
};
|
||||
|
||||
wireKindToggle();
|
||||
wireFormButtons(mode, existing);
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
}
|
||||
};
|
||||
activeFormEscHandler = escHandler;
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||
}
|
||||
|
||||
function wireFormButtons(mode: 'add' | 'edit', existing: Item | null): void {
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveTotp(mode, existing);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTotp(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||
|
||||
const secretStr = (document.getElementById('f-secret') as HTMLInputElement).value.trim();
|
||||
if (!secretStr) { setState({ error: 'Secret is required' }); return; }
|
||||
|
||||
let secretBytes: Uint8Array;
|
||||
try {
|
||||
secretBytes = base32Decode(secretStr);
|
||||
} catch (err) {
|
||||
setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
|
||||
return;
|
||||
}
|
||||
if (secretBytes.length === 0) { setState({ error: 'Secret decoded to zero bytes' }); return; }
|
||||
|
||||
const get = (id: string) => (document.getElementById(id) as HTMLInputElement).value.trim();
|
||||
|
||||
const isSteam = formKind === 'steam';
|
||||
const core = {
|
||||
type: 'totp' as const,
|
||||
config: {
|
||||
secret: Array.from(secretBytes),
|
||||
algorithm: 'sha1' as const,
|
||||
digits: isSteam ? 5 : 6,
|
||||
period_seconds: 30,
|
||||
kind: (isSteam ? 'steam' : 'totp') as TotpKind,
|
||||
},
|
||||
issuer: get('f-issuer') || undefined,
|
||||
label: get('f-label') || undefined,
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const item: Item = {
|
||||
id: existing?.id ?? '',
|
||||
title, type: 'totp',
|
||||
tags: existing?.tags ?? [],
|
||||
favorite: existing?.favorite ?? false,
|
||||
group: existing?.group, notes: existing?.notes,
|
||||
created: existing?.created ?? now,
|
||||
modified: now, trashed_at: undefined,
|
||||
core,
|
||||
sections: existing?.sections ?? [],
|
||||
attachments: existing?.attachments ?? [],
|
||||
field_history: existing?.field_history ?? {},
|
||||
};
|
||||
|
||||
setState({ loading: true, error: null });
|
||||
const resp = mode === 'add'
|
||||
? await sendMessage({ type: 'add_item', item })
|
||||
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else navigate('list');
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,12 @@ import { renderSettings } from './components/settings';
|
||||
|
||||
// --- Escape HTML to prevent XSS ---
|
||||
export function escapeHtml(str: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
@@ -38,6 +41,7 @@ export interface PopupState {
|
||||
// to the content script. See router/popup-only.ts#handleFillCredentials.
|
||||
capturedTabId: number | null;
|
||||
capturedUrl: string;
|
||||
newType: import('../shared/types').ItemType | null;
|
||||
}
|
||||
|
||||
let currentState: PopupState = {
|
||||
@@ -52,6 +56,7 @@ let currentState: PopupState = {
|
||||
loading: false,
|
||||
capturedTabId: null,
|
||||
capturedUrl: '',
|
||||
newType: null,
|
||||
};
|
||||
|
||||
export function getState(): PopupState {
|
||||
|
||||
@@ -459,3 +459,52 @@ textarea {
|
||||
border-color: #3fb950;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* --- field-row + signature-block helpers (β₁) --- */
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr auto;
|
||||
gap: 8px 10px;
|
||||
align-items: baseline;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-row__label { color: #8b949e; }
|
||||
.field-row__value { color: #c9d1d9; word-break: break-word; }
|
||||
.field-row__value.monospace { font-family: "SF Mono", "JetBrains Mono", monospace; }
|
||||
.field-row__value pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||||
}
|
||||
.field-row__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.field-row__actions button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
.field-row__actions button:hover { color: #c9d1d9; }
|
||||
|
||||
.sig-block {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-left: 3px solid #1f6feb;
|
||||
border-radius: 5px;
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sig-block--blue { border-left-color: #1f6feb; }
|
||||
.sig-block--green { border-left-color: #3fb950; }
|
||||
.sig-block--amber { border-left-color: #d29922; }
|
||||
.sig-block--red { border-left-color: #f85149; }
|
||||
|
||||
@@ -522,3 +522,135 @@ describe('capture_save_login', () => {
|
||||
expect(vault.encryptAndWriteItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- get_totp covers both Login.totp and Totp.config ---
|
||||
|
||||
describe('get_totp handler covers both Login.totp and Totp.config', () => {
|
||||
function primeUnlocked(state: RouterState): void {
|
||||
vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never);
|
||||
state.gitHost = {} as never;
|
||||
}
|
||||
|
||||
function withTotpWasm(state: RouterState): void {
|
||||
state.wasm = {
|
||||
...state.wasm,
|
||||
totp_compute: (_json: string, _now: bigint) => ({
|
||||
code: '123456',
|
||||
expires_at: 1_700_000_030,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(session.getCurrent).mockReset();
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockReset();
|
||||
});
|
||||
|
||||
it('returns a code for an item with core.type === "totp"', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
withTotpWasm(state);
|
||||
const totpItem: Item = {
|
||||
id: 'totp0000000000aa',
|
||||
title: 'GitHub TOTP',
|
||||
type: 'totp',
|
||||
tags: [],
|
||||
favorite: false,
|
||||
created: 0,
|
||||
modified: 0,
|
||||
core: {
|
||||
type: 'totp',
|
||||
config: {
|
||||
secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f], // "Hello" in bytes
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: 'totp',
|
||||
},
|
||||
issuer: 'GitHub',
|
||||
},
|
||||
sections: [],
|
||||
attachments: [],
|
||||
field_history: {},
|
||||
};
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(totpItem);
|
||||
|
||||
const res = await route(
|
||||
{ type: 'get_totp', id: 'totp0000000000aa' },
|
||||
state,
|
||||
makePopupSender(),
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
const d = res.data as { code: string; expires_at: number };
|
||||
expect(d.code).toMatch(/^\d{6}$/);
|
||||
expect(d.expires_at).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('still returns a code for Login items with a totp subfield', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
withTotpWasm(state);
|
||||
const loginItem: Item = {
|
||||
id: 'login0000000000a',
|
||||
title: 'Example',
|
||||
type: 'login',
|
||||
tags: [],
|
||||
favorite: false,
|
||||
created: 0,
|
||||
modified: 0,
|
||||
core: {
|
||||
type: 'login',
|
||||
username: 'alice',
|
||||
password: 'hunter2',
|
||||
url: 'https://example.com',
|
||||
totp: {
|
||||
secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f],
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: 'totp',
|
||||
},
|
||||
},
|
||||
sections: [],
|
||||
attachments: [],
|
||||
field_history: {},
|
||||
};
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(loginItem);
|
||||
|
||||
const res = await route(
|
||||
{ type: 'get_totp', id: 'login0000000000a' },
|
||||
state,
|
||||
makePopupSender(),
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects items without any TOTP config', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
withTotpWasm(state);
|
||||
const identityItem: Item = {
|
||||
id: 'id0000000000aaaa',
|
||||
title: 'Identity',
|
||||
type: 'identity',
|
||||
tags: [],
|
||||
favorite: false,
|
||||
created: 0,
|
||||
modified: 0,
|
||||
core: { type: 'identity' },
|
||||
sections: [],
|
||||
attachments: [],
|
||||
field_history: {},
|
||||
};
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(identityItem);
|
||||
|
||||
const res = await route(
|
||||
{ type: 'get_totp', id: 'id0000000000aaaa' },
|
||||
state,
|
||||
makePopupSender(),
|
||||
);
|
||||
expect(res).toEqual({ ok: false, error: 'no_totp' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../../shared/types';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
@@ -107,11 +107,18 @@ export async function handle(
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
if (item.core.type !== 'login' || !item.core.totp) {
|
||||
return { ok: false, error: 'no_totp' };
|
||||
// Resolve the TotpConfig from whichever carrier the item type uses.
|
||||
// Login items hold TOTP as an optional subfield on LoginCore; the standalone
|
||||
// Totp item type carries it as TotpCore.config (required).
|
||||
let cfg: TotpConfig | null = null;
|
||||
if (item.core.type === 'login' && item.core.totp) {
|
||||
cfg = item.core.totp;
|
||||
} else if (item.core.type === 'totp') {
|
||||
cfg = item.core.config;
|
||||
}
|
||||
if (!cfg) return { ok: false, error: 'no_totp' };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now));
|
||||
const code = state.wasm.totp_compute(JSON.stringify(cfg), BigInt(now));
|
||||
return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user