Compare commits
31 Commits
plan-1c-al
...
plan-1c-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba50b89e8 | ||
|
|
15fcaf9797 | ||
|
|
531af03ff1 | ||
|
|
8a16482b9c | ||
|
|
af432de320 | ||
|
|
025629cacf | ||
|
|
e47945d86a | ||
|
|
b52e49a51e | ||
|
|
6ba9ccfa4c | ||
|
|
e1d32b0379 | ||
|
|
3264cccb60 | ||
|
|
553d9d7ca9 | ||
|
|
3f12543c81 | ||
|
|
2ca563a8cd | ||
|
|
62112f50f9 | ||
|
|
81fbe132ad | ||
|
|
706051530e | ||
|
|
23759dc163 | ||
|
|
3c0b4c1589 | ||
|
|
673981379e | ||
|
|
e084790756 | ||
|
|
560a3c63c4 | ||
|
|
113b0b690a | ||
|
|
99d689b9b0 | ||
|
|
23d4f736e1 | ||
|
|
11c274053b | ||
|
|
24a99ba07a | ||
|
|
beac303a77 | ||
|
|
b80b322853 | ||
|
|
1b51b7dbab | ||
|
|
2b83105149 |
@@ -8,6 +8,10 @@ use zeroize::Zeroizing;
|
|||||||
|
|
||||||
use crate::error::{RelicarioError, Result};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct TotpCore {
|
pub struct TotpCore {
|
||||||
pub config: TotpConfig,
|
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 + 1] as u32) << 16)
|
||||||
| ((hmac_out[offset + 2] as u32) << 8)
|
| ((hmac_out[offset + 2] as u32) << 8)
|
||||||
| (hmac_out[offset + 3] as u32);
|
| (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);
|
let modulus = 10u32.pow(config.digits as u32);
|
||||||
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
||||||
}
|
}
|
||||||
@@ -168,3 +182,103 @@ mod tests {
|
|||||||
assert!(json.contains("steam"));
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
2716
docs/superpowers/plans/2026-04-22-relicario-extension-1c-beta1.md
Normal file
2716
docs/superpowers/plans/2026-04-22-relicario-extension-1c-beta1.md
Normal file
File diff suppressed because it is too large
Load Diff
2650
docs/superpowers/plans/2026-04-24-relicario-extension-1c-beta2.md
Normal file
2650
docs/superpowers/plans/2026-04-24-relicario-extension-1c-beta2.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
|||||||
|
# relicario — Extension Plan 1C-β₁ (Typed-Item Forms) Design
|
||||||
|
|
||||||
|
Second of three sub-plans porting the extension to the typed-item core. 1C-α (foundation) shipped Login-parity; 1C-β₁ adds the **other 5 typed-item forms** so the extension can daily-drive every typed item the Rust core knows about (except Document, deferred to γ for attachment dependencies). Custom-fields editor, vault-settings view, and advanced generator UI move to **β₂**.
|
||||||
|
|
||||||
|
Reference: 1C-α design `docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md` (commits `a1d733d`, `ad6d8af`); 1C-α implementation merged 2026-04-22 (`2b83105`, tag `plan-1c-alpha-complete`).
|
||||||
|
|
||||||
|
## Plan 1C decomposition (post-α refinement)
|
||||||
|
|
||||||
|
| Sub-plan | Status | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| 1C-α | shipped 2026-04-22 | WASM rebuild, shared TS types, SessionHandle SW, split router with sender checks, full security architecture, Login-parity popup, zxcvbn setup gate |
|
||||||
|
| **1C-β₁ (this spec)** | proposed | 5 typed-item forms (SecureNote / Identity / Card / Key / Totp) + Rust Steam encoding fix |
|
||||||
|
| 1C-β₂ | proposed | Custom fields editor, full vault-settings view, advanced generator-request UI |
|
||||||
|
| 1C-γ | proposed | Attachments (with `putBlob` Git-Data-API fallback), Document type, trash view, field-history view, device management |
|
||||||
|
|
||||||
|
## Design Decisions (from brainstorming)
|
||||||
|
|
||||||
|
| Question | Decision | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Does β stay one plan or split? | **β₁ + β₂** | Settings view + custom-fields editor are heavy independently; splitting unlocks daily-driver typed items as soon as β₁ ships |
|
||||||
|
| Document type in β₁? | **Defer to γ** | `DocumentCore.primary_attachment` is required; without attachment upload there's nothing to attach |
|
||||||
|
| Form visual style? | **Type-flavored, muted** | "Signature block + uniform rows" pattern: each type gets one accent panel + plain rows for the rest. Lower contrast than vivid v1 mockup, sits with the dark-terminal aesthetic |
|
||||||
|
| Totp variants in β₁? | **TOTP + Steam** (Hotp deferred) | Steam Guard is widely used; Hotp is rare and needs counter-persistence UX |
|
||||||
|
| Steam encoding in Rust core? | **Yes — fix as Slice 1** | Existing `compute_totp_code` returns decimal output for `kind: 'steam'`, which doesn't match Steam Guard. ~30 line patch + test vectors |
|
||||||
|
| Sequencing? | **5 slices: Rust Steam → shared helpers + Login refactor → SecureNote+Identity → Card+Key → Totp** | Helper extraction pays off across 5 forms; pairing trivial types together; Totp last because it depends on Steam fix |
|
||||||
|
| Custom fields in β₁? | **No — β₂** | Custom fields are the single hardest UI in β; deserves its own focused cycle |
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In
|
||||||
|
- 5 typed-item forms wired end-to-end (view + add + edit + delete): SecureNote, Identity, Card, Key, Totp.
|
||||||
|
- Form style: muted "signature block + uniform rows" with thin left-border accent per type.
|
||||||
|
- **Steam Guard** support on Totp items: `kind: 'totp'` and `kind: 'steam'` selectable in the form; UI toggle (no dropdown).
|
||||||
|
- **Rust core fix**: `compute_totp_code` learns the Steam alphabet (`23456789BCDFGHJKMNPQRTVWXY`, 5-char output).
|
||||||
|
- Concealed-with-reveal+copy pattern applied to: `Card.number`, `Card.cvv`, `Card.pin`, `Key.key_material`, `Totp.secret` (rendered as base32). Re-uses Login's existing convention via a new shared helper.
|
||||||
|
- Shared helper module `extension/src/popup/components/fields.ts` for row / concealed-row / signature-block primitives. **Login refactored onto it** as the reference implementation (net code reduction even before adding 5 new types).
|
||||||
|
- `item-detail.ts` and `item-form.ts` collapse to thin dispatchers calling `types/<x>.renderDetail()` / `renderForm()`.
|
||||||
|
- "New…" picker on the toolbar's `+ New` button, listing all 7 types (Document greyed/disabled with "coming in γ" tooltip).
|
||||||
|
- Per-type Vitest unit tests for the form→Item transform.
|
||||||
|
|
||||||
|
### Out (→ β₂ / γ)
|
||||||
|
- Custom fields editor (sections + per-field add/rename/remove/reorder). β₂.
|
||||||
|
- Vault-settings view (retention, generator defaults, attachment caps). β₂.
|
||||||
|
- Advanced generator-request UI (BIP39 vs Random, charset toggles, length slider). β₂.
|
||||||
|
- Hotp counter UI. β₂ or later.
|
||||||
|
- Per-type form custom defaults (e.g. exposing `Totp.digits` / `Totp.period_seconds`). β₂ via the custom-fields editor.
|
||||||
|
- Document type. γ.
|
||||||
|
- Attachment upload, trash view, field-history view, device-management UI. γ.
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
### New
|
||||||
|
```
|
||||||
|
crates/relicario-core/src/item_types/totp.rs # Steam alphabet output (modified)
|
||||||
|
extension/src/popup/components/fields.ts # row / concealed-row / signature-block helpers
|
||||||
|
extension/src/popup/components/types/login.ts # extracted from existing item-detail/form Login branches
|
||||||
|
extension/src/popup/components/types/secure-note.ts
|
||||||
|
extension/src/popup/components/types/identity.ts
|
||||||
|
extension/src/popup/components/types/card.ts
|
||||||
|
extension/src/popup/components/types/key.ts
|
||||||
|
extension/src/popup/components/types/totp.ts
|
||||||
|
extension/src/popup/components/__tests__/fields.test.ts
|
||||||
|
extension/src/popup/components/types/__tests__/save-shape.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
```
|
||||||
|
extension/src/popup/components/item-detail.ts # dispatch on item.type → types/<x>.renderDetail
|
||||||
|
extension/src/popup/components/item-form.ts # dispatch on item.type → types/<x>.renderForm
|
||||||
|
extension/src/popup/components/item-list.ts # "+ New" button opens type picker
|
||||||
|
extension/src/popup/styles.css # signature-block + field-row classes
|
||||||
|
crates/relicario-core/src/item_types/totp.rs # see above
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deleted
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Slice 1 — Rust Steam encoding
|
||||||
|
|
||||||
|
**File**: `crates/relicario-core/src/item_types/totp.rs`
|
||||||
|
|
||||||
|
Patch shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||||
|
|
||||||
|
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
|
||||||
|
let counter = match config.kind {
|
||||||
|
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
|
||||||
|
TotpKind::Hotp { counter } => counter,
|
||||||
|
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
|
||||||
|
};
|
||||||
|
// ... existing HMAC + dynamic-truncation logic produces `truncated: 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))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`STEAM_ALPHABET` deliberately excludes `0`, `O`, `1`, `I`, `L`, `S`, `5`, `A`, `Z`. Same alphabet used by Steam Mobile Authenticator and WinAuth.
|
||||||
|
|
||||||
|
### Tests (in the same file)
|
||||||
|
|
||||||
|
- `steam_known_vector`: pin a `(secret, counter)` to its known Steam output. If a citeable third-party vector is available, prefer it; otherwise pin the value our impl computes today (regression test against accidental future change).
|
||||||
|
- `steam_alphabet_no_ambiguous_chars`: `assert!(!STEAM_ALPHABET.contains(&b'0' / &b'O' / &b'1' / &b'I' / &b'L' / &b'S' / &b'5' / &b'A' / &b'Z'))`.
|
||||||
|
- `steam_output_is_5_chars`: regardless of `config.digits`, Steam output is exactly 5 characters.
|
||||||
|
- `totp_kind_decimal_unaffected`: existing RFC 6238 vectors for `kind: 'totp'` still pass byte-for-byte.
|
||||||
|
|
||||||
|
### WASM impact
|
||||||
|
|
||||||
|
`totp_compute` in `crates/relicario-wasm/src/lib.rs` doesn't change — it forwards `kind` through serde. The TS `TotpKind` shape in `extension/src/shared/types.ts` is already correct. Only the Rust-side compute body changes.
|
||||||
|
|
||||||
|
## Slice 2 — Shared field helpers + Login refactor
|
||||||
|
|
||||||
|
### `extension/src/popup/components/fields.ts`
|
||||||
|
|
||||||
|
Pure functions returning HTML strings + a small mount-time event-binding helper. No DOM ownership, no state.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { escapeHtml } from '../popup';
|
||||||
|
|
||||||
|
export interface RowOpts {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
copyable?: boolean;
|
||||||
|
href?: string; // wraps value in <a target="_blank" rel="noopener">
|
||||||
|
monospace?: boolean;
|
||||||
|
multiline?: boolean; // renders as <pre> instead of inline
|
||||||
|
}
|
||||||
|
export function renderRow(opts: RowOpts): string;
|
||||||
|
|
||||||
|
export interface ConcealedRowOpts {
|
||||||
|
id: string; // unique within the rendered detail view
|
||||||
|
label: string;
|
||||||
|
value: string; // plaintext; rendered hidden until user reveals
|
||||||
|
monospace?: boolean;
|
||||||
|
multiline?: boolean; // <pre> when revealed; "•••• (N chars)" when hidden
|
||||||
|
}
|
||||||
|
export function renderConcealedRow(opts: ConcealedRowOpts): string;
|
||||||
|
|
||||||
|
export interface SignatureBlockOpts {
|
||||||
|
accent?: 'blue' | 'green' | 'amber' | 'red'; // default 'blue'
|
||||||
|
children: string; // HTML, caller's responsibility to escape
|
||||||
|
}
|
||||||
|
export function renderSignatureBlock(opts: SignatureBlockOpts): string;
|
||||||
|
|
||||||
|
/// Wire reveal-toggle + copy handlers for all rows rendered above.
|
||||||
|
/// Call once after the parent's innerHTML lands.
|
||||||
|
export function wireFieldHandlers(scope: HTMLElement): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
`wireFieldHandlers` looks for `data-field-action="reveal"` and `data-field-action="copy"` attributes inside `scope` and binds click handlers. Reveal toggles a `data-revealed` attribute on the row's value `<span>`/`<pre>`; copy uses `navigator.clipboard.writeText` and flashes a 1.5s "copied" badge.
|
||||||
|
|
||||||
|
### CSS additions in `extension/src/popup/styles.css`
|
||||||
|
|
||||||
|
```css
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login refactor (same slice)
|
||||||
|
|
||||||
|
Extract `popup/components/types/login.ts` exporting `renderDetail(app, item)` / `renderForm(app, mode, existing)` / private `saveLogin(...)`. The bodies are the existing Login-branch code from `item-detail.ts` / `item-form.ts`, ported to use `renderRow` / `renderConcealedRow` / `renderSignatureBlock` instead of inline string concatenation.
|
||||||
|
|
||||||
|
Net-line check: this slice should reduce total LOC slightly (helper consolidation) before adding any new types.
|
||||||
|
|
||||||
|
### Helper unit tests (`fields.test.ts`)
|
||||||
|
|
||||||
|
- `renderRow` produces expected HTML for plain / copyable / linked / monospace / multiline cases.
|
||||||
|
- `renderConcealedRow` produces the hidden initial state, includes the unique id in `data-field-id`, has show + copy buttons, hides multiline value as `"•••• (N chars)"`.
|
||||||
|
- `renderSignatureBlock` wraps children correctly with each accent class.
|
||||||
|
- `wireFieldHandlers`: with a happy-dom `<div>` containing rendered rows, clicking the show button toggles `data-revealed`; clicking copy calls `navigator.clipboard.writeText` (mock).
|
||||||
|
|
||||||
|
## Slices 3–5 — Per-type designs
|
||||||
|
|
||||||
|
### SecureNote (Slice 3a)
|
||||||
|
|
||||||
|
**Data**: `SecureNoteCore { body: Zeroizing<String> }`.
|
||||||
|
|
||||||
|
**Detail view**: title at top, then a single signature block (accent `green`) containing the body rendered as a concealed `<pre>` block (multiline concealed row). Copy button copies the whole body verbatim. No other rows.
|
||||||
|
|
||||||
|
**Form view**: a single `<textarea>` (10-row default) for the body. Title at the top (always required on the Item envelope, not on the body field). No signature-block visual on the form — the textarea is the content.
|
||||||
|
|
||||||
|
### Identity (Slice 3b)
|
||||||
|
|
||||||
|
**Data**: `IdentityCore { full_name?, address? (multiline), phone?, email?, date_of_birth? }`.
|
||||||
|
|
||||||
|
**Detail view**: title at top; signature block (accent `amber`) with a monogram "avatar" (initials extracted from `full_name`, or `?`) + the name in larger type. Below the block, plain rows in this order: phone, email, address (multiline), date_of_birth (formatted as the user's locale via `toLocaleDateString`). Email and phone are copyable.
|
||||||
|
|
||||||
|
**Form view**: plain rows:
|
||||||
|
- `full_name`: `<input type="text">`
|
||||||
|
- `address`: `<textarea>` (3 rows)
|
||||||
|
- `phone`: `<input type="tel">`
|
||||||
|
- `email`: `<input type="email">` (browser-native validation surfaces on submit)
|
||||||
|
- `date_of_birth`: `<input type="date">` — wire format matches Rust `NaiveDate`'s `"YYYY-MM-DD"` serialization
|
||||||
|
|
||||||
|
Empty strings → `undefined` per the established convention.
|
||||||
|
|
||||||
|
### Card (Slice 4a)
|
||||||
|
|
||||||
|
**Data**: `CardCore { number?, holder?, expiry?: MonthYear, cvv?, pin?, kind: CardKind }`. `MonthYear = { month, year }`. `CardKind = 'credit' | 'debit' | 'gift' | 'loyalty' | 'other'`.
|
||||||
|
|
||||||
|
**Detail view**: title at top; signature block (accent `blue`) matching the v2 mockup:
|
||||||
|
- Top label band: `"<BRAND> · <KIND>"` uppercased (brand derived from card BIN; see below)
|
||||||
|
- Masked card number with reveal toggle, monospace, letter-spaced
|
||||||
|
- Footer: HOLDER (left) and EXPIRES (right)
|
||||||
|
|
||||||
|
Below the signature block: concealed rows for `cvv` and `pin`.
|
||||||
|
|
||||||
|
Brand derivation (display-only, not stored):
|
||||||
|
```ts
|
||||||
|
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 '';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form view**: plain rows:
|
||||||
|
- `number`: `<input type="text" inputmode="numeric">`, no formatting on the form (paste-friendly)
|
||||||
|
- `holder`: `<input type="text">`
|
||||||
|
- `expiry`: two side-by-side `<select>`s — month (`01`–`12`) + year (current ± 25). Saves as `{ month: number, year: number }`. Empty selection → `undefined` for the whole `expiry`.
|
||||||
|
- `cvv`: `<input type="password" inputmode="numeric" maxlength="4">`
|
||||||
|
- `pin`: `<input type="password" inputmode="numeric" maxlength="8">`
|
||||||
|
- `kind`: `<select>` with the 5 enum values, default `credit`
|
||||||
|
|
||||||
|
### Key (Slice 4b)
|
||||||
|
|
||||||
|
**Data**: `KeyCore { key_material: Zeroizing<String>, label?, public_key?, algorithm? }`. `key_material` is required.
|
||||||
|
|
||||||
|
**Detail view**: title at top; signature block (accent `green`) showing the `key_material` as a concealed monospace `<pre>` block. Below: plain rows for `label`, `algorithm` (free-form text), `public_key` (multiline monospace, **not concealed** — public keys are public).
|
||||||
|
|
||||||
|
**Form view**: plain rows:
|
||||||
|
- `key_material`: `<textarea>` (8 rows, monospace) with a sibling `[show]` toggle button (since `<textarea>` doesn't honor `type="password"`). Default state: a CSS rule sets `-webkit-text-security: disc` to mask characters; clicking the toggle removes the rule.
|
||||||
|
- `label`: `<input type="text">`
|
||||||
|
- `public_key`: `<textarea>` (4 rows, monospace, no masking)
|
||||||
|
- `algorithm`: `<input type="text">` placeholder `"ed25519"`
|
||||||
|
|
||||||
|
### Totp (Slice 5)
|
||||||
|
|
||||||
|
**Data**: `TotpCore { config: TotpConfig, issuer?, label? }`. `TotpConfig = { secret: number[], algorithm: 'sha1'|'sha256'|'sha512', digits: number, period_seconds: number, kind: TotpKind }`. β₁ supports `kind: 'totp'` and `kind: 'steam'`.
|
||||||
|
|
||||||
|
**Detail view**: title at top (uses `issuer / label` to construct a default if title is empty: `"<issuer>: <label>"`). Signature block (accent `blue`) shows:
|
||||||
|
- Large monospace rotating code (centered, 28pt)
|
||||||
|
- Thin SVG countdown ring at the right side, sized 32×32
|
||||||
|
|
||||||
|
Below the block: plain rows for `issuer`, `label`, and a concealed row for `secret` (rendered as base32 via `shared/base32.ts` `base32Encode`).
|
||||||
|
|
||||||
|
The ring re-tick interval is 1000ms; on each tick it calls `chrome.runtime.sendMessage({ type: 'get_totp', id })` (the existing α handler in `router/popup-only.ts` — no new message type). The countdown value is `(expires_at - now)` per the existing `TotpResponse`.
|
||||||
|
|
||||||
|
**Form view**: a kind toggle at the top, then plain rows:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ kind ──────────────────────────┐
|
||||||
|
│ [● TOTP] [○ Steam Guard] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
secret (base32): [_______________]
|
||||||
|
issuer: [_______________]
|
||||||
|
label: [_______________]
|
||||||
|
```
|
||||||
|
|
||||||
|
Toggle is a two-button group; click switches `state.kind` and re-renders the small subtitle below ("Standard time-based codes" vs "Steam Mobile Authenticator (5-char alphanumeric)"). For both kinds, `digits` / `period_seconds` / `algorithm` are written with their defaults (`6`/`30`/`sha1` for TOTP; `5`/`30`/`sha1` for Steam — Steam's compute uses the alphabet, ignoring the digits field). Power users who need non-default values use the CLI; β₂ may add a `[more options ▾]` disclosure on the Totp form if this turns out to bite real users.
|
||||||
|
|
||||||
|
`secret` parsed via `base32Decode` from `shared/base32.ts` (already exists). Empty string is rejected with a friendly error from the popup's `humanizeError` path.
|
||||||
|
|
||||||
|
### Dispatcher updates
|
||||||
|
|
||||||
|
`item-detail.ts` after β₁:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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 async function renderItemDetail(app: HTMLElement): Promise<void> {
|
||||||
|
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 renderComingSoonPlaceholder(app, item.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`item-form.ts` follows the same shape with `renderForm(app, mode, existing)`.
|
||||||
|
|
||||||
|
### "New…" picker
|
||||||
|
|
||||||
|
`item-list.ts`'s `+ New` button opens a small picker (popover anchored to the button):
|
||||||
|
|
||||||
|
```
|
||||||
|
new item
|
||||||
|
🔑 login
|
||||||
|
📝 secure note
|
||||||
|
🪪 identity
|
||||||
|
💳 card
|
||||||
|
🗝 key
|
||||||
|
⏱ totp
|
||||||
|
📄 document ← greyed; tooltip "coming in γ — needs attachment upload"
|
||||||
|
```
|
||||||
|
|
||||||
|
Selecting a type stores `state.newType` (transient — added to PopupState with `'login' | 'secure_note' | …`) and navigates to `'add'`. The form dispatcher reads `state.newType` for add-mode and `state.selectedItem.type` for edit-mode.
|
||||||
|
|
||||||
|
The popover lives in the popup's own DOM (no closed Shadow DOM needed — the popup is its own origin and not subject to page-injection threats). Standard `<div>` with `position: absolute` anchored to the button.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
|
`cargo test --workspace` stays green. New tests in `crates/relicario-core/src/item_types/totp.rs` listed in §Slice 1.
|
||||||
|
|
||||||
|
### Vitest
|
||||||
|
|
||||||
|
Existing 55 tests stay green. New:
|
||||||
|
|
||||||
|
- `extension/src/popup/components/__tests__/fields.test.ts` (helper unit tests, ~12 cases).
|
||||||
|
- `extension/src/popup/components/types/__tests__/save-shape.test.ts` (per-type form→Item transform, ~5 cases × ~3 sub-assertions = ~15 cases).
|
||||||
|
|
||||||
|
The save-shape tests use happy-dom to render each form's HTML, populate inputs, fire the save handler, and intercept the `add_item` message via a `vi.fn()` shim of `chrome.runtime.sendMessage`. Asserts cover:
|
||||||
|
|
||||||
|
- SecureNote: `core.body === '<input value>'`, `core.type === 'secure_note'`.
|
||||||
|
- Identity: each present field in JS shape matches the wire format; absent fields are `undefined` (not empty string).
|
||||||
|
- Card: `expiry === { month: 8, year: 2029 }`; concealed fields (`number`/`cvv`/`pin`) round-trip through the form values; `kind` matches the select.
|
||||||
|
- Key: `key_material` always present; `algorithm` free-form.
|
||||||
|
- Totp: `config.secret === Array.from(base32Decode('JBSWY3DPEHPK3PXP'))`; `config.kind === 'totp'` or `'steam'` depending on toggle; for Steam, `config.digits === 5`.
|
||||||
|
|
||||||
|
### Manual matrix
|
||||||
|
|
||||||
|
Re-run the α matrix's 11 steps (§5.4 of α spec) plus, per type:
|
||||||
|
|
||||||
|
1. Add a new item of the type → it appears in the list with the right icon.
|
||||||
|
2. Open the item → detail view renders correctly (signature block + rows; no console errors).
|
||||||
|
3. For types with concealed fields: click reveal → value appears; click copy → clipboard contains the value.
|
||||||
|
4. Edit → save → list updates with new modified time; detail reflects changes.
|
||||||
|
5. Trash → moves out of the live list; CLI `relicario list --trashed` shows it.
|
||||||
|
6. For Totp: code rotates every 30s; Steam Guard kind produces 5-char alphanumeric; TOTP kind produces 6-digit decimal; switching kinds in the edit form re-renders the detail view's compute output correctly after save.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- `cargo test --workspace` green.
|
||||||
|
- `bun run test` green.
|
||||||
|
- `bun run build:all` green for both Chrome and Firefox.
|
||||||
|
- `git grep -n 'coming-soon\|Coming in' extension/src/popup/components/` returns hits ONLY for `'document'`.
|
||||||
|
- All 5 type matrices pass on Chrome and Firefox.
|
||||||
|
- No new lint regressions; `git grep -n '@ts-nocheck' extension/src/` returns zero.
|
||||||
|
|
||||||
|
## Open questions deferred to the plan
|
||||||
|
|
||||||
|
- Exact CSS sizing of the Totp signature block's countdown ring (32px or 40px). Picked at implementation time.
|
||||||
|
- Whether the Card brand-from-BIN is comprehensive enough (currently 4 brands). Likely fine for α/β₁ — extending the table is a one-line change.
|
||||||
|
- For Steam toggle UX: a two-button group or a dropdown. Brainstorming locked in two-button; implementation may push back if it's awkward at popup width.
|
||||||
|
- Whether to expose `Totp.algorithm` / `digits` / `period_seconds` to power users via a `[more options ▾]` disclosure on the form. β₁ defaults them; β₂ revisits if the CLI workaround friction is real.
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
# relicario — Extension Plan 1C-β₂ (Custom Fields + Settings + Generator UI) Design
|
||||||
|
|
||||||
|
Third of three β sub-plans porting the extension to the typed-item core. 1C-α shipped the security architecture + Login parity; 1C-β₁ added the 5 remaining typed-item forms; **1C-β₂** (this spec) adds the cross-cutting UI surfaces: custom fields editor, full vault-settings view, and an inline generator popover.
|
||||||
|
|
||||||
|
Reference specs: `docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md` (α, commits `a1d733d` + `ad6d8af`), `docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta1-design.md` (β₁, commit `1b51b7d`). Both implementations merged to main: α at `2b83105` (tag `plan-1c-alpha-complete`), β₁ at `81fbe13` (tag `plan-1c-beta1-complete`).
|
||||||
|
|
||||||
|
## Plan 1C decomposition (final shape)
|
||||||
|
|
||||||
|
| Sub-plan | Status | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| 1C-α | shipped 2026-04-22 | WASM rebuild, typed-item shared TS types, SessionHandle SW, split router with sender checks, closed Shadow DOM content scripts, Login-parity popup, zxcvbn setup gate |
|
||||||
|
| 1C-β₁ | shipped 2026-04-22 | 5 remaining typed-item forms (SecureNote / Identity / Card / Key / Totp) + Rust Steam-Guard alphabet patch; shared field helpers + Login refactor |
|
||||||
|
| **1C-β₂** (this spec) | proposed | Custom-fields editor (Text/Password/Concealed), full VaultSettings view (retention + generator defaults + origin-ack revoke), advanced generator popover |
|
||||||
|
| 1C-γ | proposed | Attachments (with `putBlob` Git-Data-API fallback), Document type, trash view, field-history view, device management, attachment caps UI |
|
||||||
|
|
||||||
|
## Design Decisions (from brainstorming)
|
||||||
|
|
||||||
|
| Question | Decision | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Custom-fields scope | **Tier 1 — Text/Password/Concealed only, no reordering** | The other 8 FieldKinds (Url/Email/Phone/Date/MonthYear/Totp/Reference/Multiline) each add real UX work; tier 1 covers the "recovery codes, security questions" 90% case. Reordering and additional kinds live in a later polish pass. |
|
||||||
|
| VaultSettings scope | **Retention + generator defaults + origin-ack revoke; skip attachment caps** | Attachment caps govern a feature that doesn't ship until γ. Ship the caps UI alongside the feature. |
|
||||||
|
| Generator UI location | **Inline popover + Settings preview** | One underlying `GeneratorRequest` config, two entry points. Matches 1Password/Bitwarden. "save as default" in the popover updates Settings without forcing the user to navigate. |
|
||||||
|
| Custom-fields edit-view placement | **Collapsible disclosure ("▸ custom sections & fields (N)")** | Most items never grow custom fields; always-visible editor adds clutter for the 90% case. Count-hint on the disclosure gives discoverability without noise. |
|
||||||
|
| Sequencing | **5 slices: detail render → edit render → vault-settings SW (+ generate_passphrase if missing) → generator popover → settings view** | Matches β₁'s cadence. SW plumbing lands before the popover so "save as default" is fully functional the moment the popover ships. |
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In
|
||||||
|
|
||||||
|
- **Custom-fields rendering** (detail view): `Item.sections` rendered below typed rows via a new `renderSections(item, idPrefix)` helper in `fields.ts`. Sections with ≥1 field render a header (named) or thin separator (anonymous). Fields of kind `text` render via `renderRow`; `password`/`concealed` via `renderConcealedRow` with per-section unique IDs.
|
||||||
|
|
||||||
|
- **Custom-fields editor** (edit view): collapsible disclosure ("▸ custom sections & fields (N)") at the bottom of every type's form. Expanded state shows each section's rename/remove buttons, per-field label + value inputs + `×` delete, and per-section `[+ text] [+ password] [+ concealed]` buttons. A `[+ add section]` button at the bottom. Sections have optional names (rename via `prompt()`; clear to make anonymous). Save packs `sectionsDraft` into the outgoing `Item.sections`.
|
||||||
|
|
||||||
|
- **FieldKind support**: `text`, `password`, `concealed` only. `Url` / `Email` / `Phone` / `Date` / `MonthYear` / `Totp` / `Reference` / `Multiline` all remain Rust-core-only (the data model supports them; the popup doesn't render editors for them in β₂).
|
||||||
|
|
||||||
|
- **No reordering**: new fields append to their section's `fields` array; new sections append to `item.sections`. Rendering preserves array order. A future polish pass can add up/down arrows or drag handles.
|
||||||
|
|
||||||
|
- **Full VaultSettings view**: new `popup/components/settings-vault.ts` screen wired to the ⚙ toolbar button (now a tiny picker: device / vault). Covers:
|
||||||
|
- Trash retention (`Days(N)` / `Forever`) via a preset dropdown (Forever / 7 / 30 / 60 / 90 / 180 / 365 / custom days).
|
||||||
|
- Field-history retention (`LastN(N)` / `Days(N)` / `Forever`) via a preset dropdown (Forever / Last 3 / Last 5 / Last 10 / 30 days / 90 days / 365 days / custom).
|
||||||
|
- Generator-default preview with a "configure ▾" button that opens the same generator popover used at form "gen" sites; "save as default" closes the loop.
|
||||||
|
- Origin-ack list (`autofill_origin_acks`) sorted by most-recent first, with per-host revoke buttons.
|
||||||
|
- Save-changes / discard buttons; save disabled until `pendingSettings` differs from `vaultSettings`.
|
||||||
|
|
||||||
|
- **Advanced generator popover**: new `popup/components/generator-popover.ts`. Anchored to the "gen" button; positioned absolutely below. Kind toggle (Random / BIP39). Random knobs: length slider (8-64), 4 char-class checkboxes, symbol-charset toggle (safe_only / extended / custom). BIP39 knobs: word count slider (3-12), separator chip picker (space / `-` / `_` / `.` / `:`), capitalization picker (lower / upper / first-of-each / title). Live preview via `generate_password` / `generate_passphrase` message on 150ms debounce. Four action buttons: `reset to defaults`, `save as default`, `cancel`, `use this value`. Validation: "use this value" disabled when no char class selected for Random kind.
|
||||||
|
|
||||||
|
- **New popup-only messages**: `get_vault_settings` → returns full `VaultSettings`. `update_vault_settings` → writes full `VaultSettings`. Both added to `POPUP_ONLY_TYPES`; not in `SETUP_ALLOWED`. Router test matrix grows by 4 cases (accept from popup × 2, reject from content × 2).
|
||||||
|
|
||||||
|
- **Teardown integration**: every type module's `teardown()` gains `closeGeneratorPopover()`. The collapsible disclosure's expanded-state (`sectionsExpanded: boolean`) is module-scope and reset by `teardown()`.
|
||||||
|
|
||||||
|
### Out (→ γ / later)
|
||||||
|
|
||||||
|
- Reordering (sections or fields-within-section).
|
||||||
|
- Other FieldKind variants (Url/Email/Phone/Date/MonthYear/Totp/Reference/Multiline).
|
||||||
|
- Attachment caps UI (γ concern, bundled with attachments).
|
||||||
|
- Bulk custom-field operations (delete-many, template, import-from-CSV).
|
||||||
|
- Per-type section templates (e.g., Card auto-creates a "billing address" section).
|
||||||
|
- Item-to-item `Reference` pointers (requires attachment picker).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data flow additions
|
||||||
|
|
||||||
|
1. **Custom fields**: already present end-to-end — the Rust core's `Item.sections: Vec<Section>` + `Section.fields: Vec<Field>` + `Field.value: FieldValue` data model is complete. β₁'s save paths already pass `sections: existing?.sections ?? []` through. β₂ just grows the UI to produce and consume that shape. No SW message changes.
|
||||||
|
|
||||||
|
2. **Vault settings**: α plumbed `fetchAndDecryptSettings` / `encryptAndWriteSettings` through `service-worker/vault.ts` for the autofill origin-ack writes. β₂ exposes the full `VaultSettings` object via two new popup-only messages. No new Rust or WASM work.
|
||||||
|
|
||||||
|
3. **Generator popover**: already has all the plumbing it needs — α's `generate_password` / `generate_passphrase` messages accept an arbitrary `GeneratorRequest` and route to the WASM layer. β₂ just wires a UI.
|
||||||
|
|
||||||
|
### Module boundaries
|
||||||
|
|
||||||
|
```
|
||||||
|
popup/components/
|
||||||
|
fields.ts (extended) — + renderSections, renderSectionsEditor,
|
||||||
|
wireSectionsEditor, generateFieldId
|
||||||
|
generator-popover.ts (new) — openGeneratorPopover, closeGeneratorPopover
|
||||||
|
settings-vault.ts (new) — renderVaultSettings
|
||||||
|
item-list.ts (edit) — ⚙ toolbar button → device/vault picker
|
||||||
|
types/login.ts (edit) — + sections tail in renderDetail;
|
||||||
|
+ disclosure in renderForm;
|
||||||
|
+ generator popover wire on "gen" button;
|
||||||
|
+ closeGeneratorPopover in teardown
|
||||||
|
types/{secure-note,identity,card,key,totp}.ts (edit) — same integration pattern
|
||||||
|
|
||||||
|
service-worker/
|
||||||
|
router/popup-only.ts (edit) — + get_vault_settings, update_vault_settings
|
||||||
|
|
||||||
|
shared/
|
||||||
|
messages.ts (edit) — + 2 new PopupMessage variants, added to POPUP_ONLY_TYPES
|
||||||
|
types.ts (unchanged)
|
||||||
|
|
||||||
|
popup/popup.ts (edit) — + vaultSettings + generatorDefaults in PopupState;
|
||||||
|
+ fetch after unlock; + settings-vault view route
|
||||||
|
```
|
||||||
|
|
||||||
|
### PopupState additions
|
||||||
|
|
||||||
|
```ts
|
||||||
|
vaultSettings: VaultSettings | null; // cached on unlock; refreshed on save
|
||||||
|
generatorDefaults: GeneratorRequest | null; // derived from vaultSettings.generator_defaults
|
||||||
|
view: 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault';
|
||||||
|
```
|
||||||
|
|
||||||
|
The `'settings-vault'` view routes to the new `renderVaultSettings`.
|
||||||
|
|
||||||
|
## Slice 1 — Custom-fields detail rendering
|
||||||
|
|
||||||
|
### `fields.ts#renderSections`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function renderSections(item: Item, idPrefix: string): string;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Walks `item.sections`. For each section with ≥1 field:
|
||||||
|
- If `section.name` truthy: emit `<div class="section-header">{escaped name}</div>`
|
||||||
|
- Else (anonymous): emit `<hr class="section-separator">`
|
||||||
|
- For each field:
|
||||||
|
- `field.value.kind === 'text'` → `renderRow({ label: field.label, value: field.value.value, copyable: true })`
|
||||||
|
- `field.value.kind === 'password'` / `'concealed'` → `renderConcealedRow({ id: `${idPrefix}-s${sectionIdx}-f${fieldIdx}`, label: field.label, value: field.value.value })`
|
||||||
|
- Other kinds: silently skip in β₂ (the Rust core may carry other-kind fields from the CLI; we render what we support).
|
||||||
|
|
||||||
|
### Per-type integration
|
||||||
|
|
||||||
|
Every type module's `renderDetail` gets a call to `renderSections` between typed rows and action buttons:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad">
|
||||||
|
${/* signature block + typed rows */}
|
||||||
|
${renderSections(item, '<type>')} // ← added
|
||||||
|
${/* form-actions */}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
`wireFieldHandlers(app)` call already at the bottom of each type module picks up the new reveal/copy buttons in custom-field rows.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`types/__tests__/sections-render.test.ts`:
|
||||||
|
- Empty `item.sections` → `renderSections` returns empty string.
|
||||||
|
- One named section with 2 text fields → contains the section name + both field labels + both values as visible text.
|
||||||
|
- Mixed text + password fields → password value concealed (not in visible DOM text); has reveal button.
|
||||||
|
- Anonymous section → separator HR, no name header.
|
||||||
|
- Unsupported kind (e.g., a `date` field from the CLI) → silently skipped, no error.
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
.section-header {
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.section-separator { margin: 10px 0 4px; border: 0; border-top: 1px solid #21262d; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slice 2 — Custom-fields edit rendering
|
||||||
|
|
||||||
|
### `fields.ts#renderSectionsEditor` + `wireSectionsEditor`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function renderSectionsEditor(sections: Section[], expanded: boolean): string;
|
||||||
|
|
||||||
|
/// Wire handlers for the editor's interactive elements. Mutations to
|
||||||
|
/// `sectionsDraft` are reflected by `rerender()` — callers implement
|
||||||
|
/// rerender by re-running `renderSectionsEditor` + inserting it back
|
||||||
|
/// into the disclosure's body element.
|
||||||
|
export function wireSectionsEditor(
|
||||||
|
scope: HTMLElement,
|
||||||
|
sectionsDraft: Section[],
|
||||||
|
rerender: () => void,
|
||||||
|
): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout (expanded state)
|
||||||
|
|
||||||
|
```
|
||||||
|
▾ custom sections & fields (2 sections, 5 fields)
|
||||||
|
|
||||||
|
── recovery codes ────── [rename] [× remove section]
|
||||||
|
[label_________] [value_________________] [×]
|
||||||
|
[label_________] [value_________________] [×]
|
||||||
|
[+ text] [+ password] [+ concealed]
|
||||||
|
|
||||||
|
── (anonymous) ───────── [rename] [× remove section]
|
||||||
|
[label_________] [value_________________] [×]
|
||||||
|
[+ text] [+ password] [+ concealed]
|
||||||
|
|
||||||
|
[+ add section]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `generateFieldId`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/// Client-side 16-char hex FieldId. Uses crypto.getRandomValues for
|
||||||
|
/// 8 random bytes; matches the wire-format requirement. No SW round-trip.
|
||||||
|
export function generateFieldId(): string {
|
||||||
|
const bytes = new Uint8Array(8);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutations
|
||||||
|
|
||||||
|
- **Add section**: `sectionsDraft.push({ name: undefined, fields: [] })`; rerender.
|
||||||
|
- **Rename section**: `prompt('Section name (empty for none):', section.name ?? '')`; set `sectionsDraft[i].name = result.trim() || undefined`; rerender.
|
||||||
|
- **Remove section**: `confirm('Remove section ...?')`; `sectionsDraft.splice(i, 1)`; rerender.
|
||||||
|
- **Add field** (kind K): `sectionsDraft[i].fields.push(makeField(K))`; rerender. Helper:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function makeField(kind: 'text' | 'password' | 'concealed'): Field {
|
||||||
|
const hidden = kind !== 'text';
|
||||||
|
return {
|
||||||
|
id: generateFieldId(),
|
||||||
|
label: 'new field',
|
||||||
|
kind,
|
||||||
|
value: { kind, value: '' } as FieldValue,
|
||||||
|
hidden_by_default: hidden,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Remove field**: `sectionsDraft[i].fields.splice(j, 1)`; rerender.
|
||||||
|
- **Edit field label**: `input` event on label input mutates `sectionsDraft[i].fields[j].label` in place. No rerender (would steal focus).
|
||||||
|
- **Edit field value**: `input` event mutates `sectionsDraft[i].fields[j].value.value` in place. No rerender.
|
||||||
|
|
||||||
|
### Per-type form integration
|
||||||
|
|
||||||
|
Each of the 6 type modules (`types/<x>.ts`):
|
||||||
|
|
||||||
|
1. At the top of `renderForm`, initialize a local `sectionsDraft: Section[] = existing?.sections.map(deepClone) ?? []` (deep clone so cancel doesn't mutate the pre-existing item).
|
||||||
|
2. Add `let sectionsExpanded = false;` at module scope, reset by `teardown()`.
|
||||||
|
3. Insert `${renderSectionsEditor(sectionsDraft, sectionsExpanded)}` in the form HTML, just before `<div class="form-actions">`.
|
||||||
|
4. After `app.innerHTML = ...`, call `wireSectionsEditor(app, sectionsDraft, rerender)` where `rerender` replaces the disclosure subtree's innerHTML with a fresh `renderSectionsEditor(sectionsDraft, sectionsExpanded)`.
|
||||||
|
5. In save, replace `sections: existing?.sections ?? []` with `sections: sectionsDraft`.
|
||||||
|
|
||||||
|
`deepClone` helper: `JSON.parse(JSON.stringify(existing.sections))` is sufficient for the `Section[]` shape (no class instances, no Date objects, no undefined in positions that need to survive).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`types/__tests__/sections-edit.test.ts`:
|
||||||
|
- Open form (add mode), click disclosure toggle → data-expanded flips true.
|
||||||
|
- Click "+ add section" → one section appears; its field list is empty.
|
||||||
|
- Rename the section via mocked `window.prompt` → section header updates.
|
||||||
|
- Click "+ text" → a text field appears with label "new field" and empty value.
|
||||||
|
- Edit the label + value inputs → assertions on the in-memory sectionsDraft.
|
||||||
|
- Click save → `add_item` message's `item.sections` matches the draft structure.
|
||||||
|
- Round-trip on edit mode: pre-populate `existing` with sections, open form, confirm sections render expanded (since count > 0), add a field, save → outgoing sections has the new field appended.
|
||||||
|
|
||||||
|
### CSS additions
|
||||||
|
|
||||||
|
```css
|
||||||
|
.disclosure {
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.disclosure__toggle {
|
||||||
|
background: transparent; border: 0; color: #58a6ff;
|
||||||
|
cursor: pointer; font-size: 12px; padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.disclosure[data-expanded="false"] .disclosure__body { display: none; }
|
||||||
|
.section-editor__head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
margin-top: 10px; margin-bottom: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.section-editor__head .name { color: #c9d1d9; font-weight: 600; }
|
||||||
|
.section-editor__head .name.anon { color: #8b949e; font-style: italic; }
|
||||||
|
.section-editor__head .actions { color: #8b949e; font-size: 10px; margin-left: auto; }
|
||||||
|
.section-editor__head .actions button { background: transparent; border: 0; color: inherit; cursor: pointer; padding: 0; margin-left: 8px; }
|
||||||
|
.section-editor__field {
|
||||||
|
display: grid; grid-template-columns: 120px 1fr auto;
|
||||||
|
gap: 4px; margin-bottom: 4px; font-size: 11px;
|
||||||
|
}
|
||||||
|
.section-editor__field input {
|
||||||
|
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||||||
|
padding: 3px 6px; border-radius: 3px; font: inherit; font-size: 11px;
|
||||||
|
}
|
||||||
|
.section-editor__field .delete-field {
|
||||||
|
background: transparent; border: 0; color: #f85149; cursor: pointer;
|
||||||
|
font-size: 14px; padding: 0 4px;
|
||||||
|
}
|
||||||
|
.section-editor__add {
|
||||||
|
display: flex; gap: 6px; margin-top: 6px;
|
||||||
|
}
|
||||||
|
.section-editor__add button {
|
||||||
|
background: transparent; border: 1px solid #30363d; color: #8b949e;
|
||||||
|
padding: 2px 10px; border-radius: 3px; cursor: pointer; font-size: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.section-editor__add button:hover { color: #c9d1d9; border-color: #484f58; }
|
||||||
|
.disclosure__body .add-section {
|
||||||
|
margin-top: 12px; background: transparent;
|
||||||
|
border: 1px dashed #30363d; color: #8b949e;
|
||||||
|
padding: 6px 10px; border-radius: 4px; cursor: pointer;
|
||||||
|
width: 100%; font-size: 11px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slice 3 — Vault-settings SW plumbing
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
`shared/messages.ts` — add to `PopupMessage`:
|
||||||
|
```ts
|
||||||
|
| { type: 'get_vault_settings' }
|
||||||
|
| { type: 'update_vault_settings'; settings: VaultSettings }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add both to `POPUP_ONLY_TYPES`. NOT in `SETUP_ALLOWED`.
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```ts
|
||||||
|
export interface VaultSettingsResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { settings: VaultSettings };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handlers (`router/popup-only.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case 'get_vault_settings': {
|
||||||
|
const handle = session.getCurrent();
|
||||||
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
|
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||||||
|
return { ok: true, data: { settings } };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update_vault_settings': {
|
||||||
|
const handle = session.getCurrent();
|
||||||
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
|
await vault.encryptAndWriteSettings(
|
||||||
|
state.gitHost, handle, msg.settings,
|
||||||
|
'settings: update vault-level config',
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Router tests
|
||||||
|
|
||||||
|
`router/__tests__/router.test.ts` (+4 cases):
|
||||||
|
- `get_vault_settings` accepted from popup (mock `fetchAndDecryptSettings` → returns a `VaultSettings`); response shape matches `VaultSettingsResponse`.
|
||||||
|
- `get_vault_settings` rejected from content → `unauthorized_sender`.
|
||||||
|
- `update_vault_settings` accepted from popup; calls `encryptAndWriteSettings`.
|
||||||
|
- `update_vault_settings` rejected from setup tab (not in SETUP_ALLOWED).
|
||||||
|
|
||||||
|
### Popup init
|
||||||
|
|
||||||
|
`popup.ts#init`, after a successful unlock-is-active branch:
|
||||||
|
```ts
|
||||||
|
const vsResp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (vsResp.ok) {
|
||||||
|
const vs = (vsResp.data as { settings: VaultSettings }).settings;
|
||||||
|
currentState.vaultSettings = vs;
|
||||||
|
currentState.generatorDefaults = vs.generator_defaults as GeneratorRequest;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetched once at popup open; refreshed after any `update_vault_settings` success. The "fetch on open" cost is one extra round-trip over α — acceptable given vault-settings drives multiple screens.
|
||||||
|
|
||||||
|
### `generate_passphrase` message (add if missing)
|
||||||
|
|
||||||
|
The α plan lists `generate_password` as a popup-only message. The generator popover (Slice 4) also needs `generate_passphrase` for BIP39 preview. Check `shared/messages.ts`; if absent, add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
| { type: 'generate_passphrase'; request: GeneratorRequest }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `POPUP_ONLY_TYPES`. The SW handler mirrors `generate_password` but calls the `generate_passphrase` WASM function. One new case in `router/popup-only.ts`.
|
||||||
|
|
||||||
|
## Slice 4 — Generator inline popover
|
||||||
|
|
||||||
|
### `popup/components/generator-popover.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function openGeneratorPopover(opts: {
|
||||||
|
anchor: HTMLElement;
|
||||||
|
initial: GeneratorRequest;
|
||||||
|
onPicked: (value: string) => void;
|
||||||
|
}): void;
|
||||||
|
|
||||||
|
export function closeGeneratorPopover(): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Module-scope state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let activePopover: {
|
||||||
|
host: HTMLElement;
|
||||||
|
onDismiss: () => void;
|
||||||
|
} | null = null;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout (Random kind)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ generate ────────────────── ✕ ┐
|
||||||
|
│ │
|
||||||
|
│ kind: [● Random] [○ BIP39] │
|
||||||
|
│ │
|
||||||
|
│ length: [════●═══════] 20 │
|
||||||
|
│ │
|
||||||
|
│ ☑ lowercase ☑ digits │
|
||||||
|
│ ☑ uppercase ☑ symbols │
|
||||||
|
│ │
|
||||||
|
│ symbols: [● safe] [○ extended] │
|
||||||
|
│ │
|
||||||
|
│ ─ preview ──────────────────── │
|
||||||
|
│ Kj7%pW@2xNq!8rMvT [↻] │
|
||||||
|
│ │
|
||||||
|
│ [reset to defaults] │
|
||||||
|
│ [save as default] │
|
||||||
|
│ [cancel] [use this value] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout (BIP39 kind)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ generate ────────────────── ✕ ┐
|
||||||
|
│ kind: [○ Random] [● BIP39] │
|
||||||
|
│ │
|
||||||
|
│ words: [═══●════════] 5 │
|
||||||
|
│ │
|
||||||
|
│ separator: [space] [-] [_] [.] [:]
|
||||||
|
│ │
|
||||||
|
│ capitalization: │
|
||||||
|
│ [● lower] [upper] [first] [title]
|
||||||
|
│ │
|
||||||
|
│ ─ preview ──────────────────── │
|
||||||
|
│ correct horse battery staple parapet
|
||||||
|
│ │
|
||||||
|
│ [reset to defaults] │
|
||||||
|
│ [save as default] │
|
||||||
|
│ [cancel] [use this value] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request construction
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function buildRequest(kind: 'random' | 'bip39', knobs: UiKnobs): GeneratorRequest {
|
||||||
|
if (kind === 'random') {
|
||||||
|
return {
|
||||||
|
kind: 'random',
|
||||||
|
length: knobs.length,
|
||||||
|
classes: {
|
||||||
|
lower: knobs.lower, upper: knobs.upper,
|
||||||
|
digits: knobs.digits, symbols: knobs.symbols,
|
||||||
|
},
|
||||||
|
symbol_charset:
|
||||||
|
knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } :
|
||||||
|
knobs.symbolCharset === 'extended' ? { kind: 'extended' } :
|
||||||
|
{ kind: 'custom', value: knobs.customSymbols ?? '' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'bip39',
|
||||||
|
word_count: knobs.wordCount,
|
||||||
|
separator: knobs.separator,
|
||||||
|
capitalization: knobs.capitalization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preview refresh
|
||||||
|
|
||||||
|
On any knob change, debounced 150ms:
|
||||||
|
```ts
|
||||||
|
async function refreshPreview(): Promise<void> {
|
||||||
|
const request = buildRequest(uiKind, uiKnobs);
|
||||||
|
const msg = uiKind === 'random'
|
||||||
|
? { type: 'generate_password' as const, request }
|
||||||
|
: { type: 'generate_passphrase' as const, request };
|
||||||
|
const resp = await sendMessage(msg);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { password?: string; passphrase?: string };
|
||||||
|
const previewEl = activePopover?.host.querySelector('.gen-preview__value');
|
||||||
|
if (previewEl) previewEl.textContent = data.password ?? data.passphrase ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: α added `generate_password` but `generate_passphrase` may need to be added (check α's `messages.ts`). If not present, add it alongside generate_password in slice 4's scope (router handler already accepts a `request_json` → WASM `generate_passphrase`).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
"use this value" button disabled when:
|
||||||
|
- Random kind and no char-class checked (`!lower && !upper && !digits && !symbols`).
|
||||||
|
- BIP39 kind never disabled (always valid — word count ≥ 3).
|
||||||
|
|
||||||
|
Visual cue: when disabled, button is dimmed + a `<p class="gen-validation">pick at least one character class</p>` renders below.
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
- **use this value**: `onPicked(currentPreview); close();`. Host field's setter wraps this (e.g., `pw.value = value; pw.type = 'text';` for the Login form).
|
||||||
|
- **save as default**: fetch the full `vaultSettings` via `sendMessage({ type: 'get_vault_settings' })`; write `{ ...vaultSettings, generator_defaults: currentRequest }` via `update_vault_settings`. On success: update `state.vaultSettings` + `state.generatorDefaults`; flash "saved" on the button for 1.5s; do NOT close.
|
||||||
|
- **reset to defaults**: reset UI knobs to `state.generatorDefaults ?? DEFAULT_PASSWORD_REQUEST`; refresh preview.
|
||||||
|
- **cancel / Escape / outside-click**: close without callback.
|
||||||
|
|
||||||
|
### Teardown wiring
|
||||||
|
|
||||||
|
Every type module's existing `teardown()` gains:
|
||||||
|
```ts
|
||||||
|
closeGeneratorPopover();
|
||||||
|
```
|
||||||
|
So navigation or re-rendering always cleans up the popover.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`__tests__/generator-popover.test.ts` (mocks `sendMessage`):
|
||||||
|
- Open with default initial → renders Random kind, shows `length=20`, all 4 classes checked, safe_only.
|
||||||
|
- BIP39 toggle → switches knobs to word-count / separator / capitalization; `sendMessage` called with `generate_passphrase`.
|
||||||
|
- Length slider change → debounced `generate_password` call with updated `length`.
|
||||||
|
- "use this value" → `onPicked` called with current preview string; popover closes.
|
||||||
|
- "save as default" → `update_vault_settings` called with the current request merged into vaultSettings.
|
||||||
|
- Uncheck all 4 classes in Random → "use this value" button disabled.
|
||||||
|
- Escape key → popover closes without invoking onPicked.
|
||||||
|
|
||||||
|
## Slice 5 — Settings view + revoke + default wiring
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
`popup.ts`:
|
||||||
|
- Add `'settings-vault'` to the `View` union.
|
||||||
|
- Add the render-switch case pointing at `renderVaultSettings`.
|
||||||
|
- Toolbar ⚙ button on `item-list.ts` becomes a tiny picker (render inline, same pattern as the "+ New" picker):
|
||||||
|
|
||||||
|
```
|
||||||
|
⚙
|
||||||
|
├ device settings → navigate('settings')
|
||||||
|
└ vault settings → navigate('settings-vault')
|
||||||
|
```
|
||||||
|
|
||||||
|
### `popup/components/settings-vault.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function renderVaultSettings(app: HTMLElement): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Module-scope state:
|
||||||
|
- `pendingSettings: VaultSettings | null` — draft, initialized from `state.vaultSettings`, mutated by the screen.
|
||||||
|
- `teardown()` exported; removes any active key handler.
|
||||||
|
|
||||||
|
### Render body
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="pad">
|
||||||
|
<div class="settings-header">
|
||||||
|
<button class="btn" id="back-btn">← back</button>
|
||||||
|
<h3>vault settings</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">retention</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">trash</span>
|
||||||
|
<select id="trash-retention">...</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">field history</span>
|
||||||
|
<select id="history-retention">...</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">generator</div>
|
||||||
|
<p class="gen-preview-line">{humanSummary(pending.generator_defaults)}</p>
|
||||||
|
<button class="btn" id="configure-gen">configure ▾</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">autofill origins</div>
|
||||||
|
{if empty: <p class="muted">No origins acknowledged yet.</p>}
|
||||||
|
{else: sorted ack rows with revoke buttons}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button class="btn" id="discard-btn">discard</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retention dropdown semantics
|
||||||
|
|
||||||
|
`retentionSelectOptions(kind: 'trash' | 'history')`:
|
||||||
|
- Trash: `Forever`, `7 days`, `30 days`, `60 days`, `90 days`, `180 days`, `365 days`, `custom…`.
|
||||||
|
- History: `Forever`, `Last 3`, `Last 5`, `Last 10`, `30 days`, `90 days`, `365 days`, `custom…`.
|
||||||
|
|
||||||
|
`retentionToSelectValue(r)` maps a `TrashRetention` / `HistoryRetention` union to one of those option labels (falling back to `custom…` if it's an N that doesn't match a preset).
|
||||||
|
|
||||||
|
`selectValueToRetention(kind, label)` goes the other way. For `custom…`, `prompt()` the user for a number + unit.
|
||||||
|
|
||||||
|
### Generator-default preview
|
||||||
|
|
||||||
|
`humanSummary(req: GeneratorRequest): string`:
|
||||||
|
- Random: `"Random, {length} chars, {classes joined with +}, {symbolCharset label}"`.
|
||||||
|
- BIP39: `"BIP39, {word_count} words, {separator label}-separated, {capitalization}"`.
|
||||||
|
|
||||||
|
Clicking "configure ▾" opens the generator popover (`openGeneratorPopover`) with `onPicked: () => {}` (no-op — the user's intent here is "save as default", not "insert into a field"). On popover close (after save-as-default or cancel), refresh `state.vaultSettings` via a `get_vault_settings` round-trip and re-render the settings screen. (The popover's "save as default" already calls `update_vault_settings` itself.)
|
||||||
|
|
||||||
|
### Origin-ack list
|
||||||
|
|
||||||
|
Sorted by `Object.entries(acks).sort(([, a], [, b]) => b - a)` (most recent first).
|
||||||
|
|
||||||
|
Each row:
|
||||||
|
```html
|
||||||
|
<div class="ack-row">
|
||||||
|
<span class="ack-row__host">github.com</span>
|
||||||
|
<span class="ack-row__meta">acked 3d ago</span>
|
||||||
|
<button class="ack-row__revoke" data-host="github.com">revoke</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Revoke handler: `delete pending.autofill_origin_acks[host]; rerender(); markDirty();`.
|
||||||
|
|
||||||
|
### Save / discard
|
||||||
|
|
||||||
|
`markDirty()` enables the save button. `save` sends `update_vault_settings` with `pending`; on success, updates `state.vaultSettings` + `state.generatorDefaults` and navigates back to the list. `discard` just navigates back.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`__tests__/settings-vault.test.ts`:
|
||||||
|
- Render with seeded `state.vaultSettings` — correct retention labels shown.
|
||||||
|
- Change trash-retention select → `pending` updated; save button enabled.
|
||||||
|
- Click revoke on an ack → `pending.autofill_origin_acks` loses that key; save button enabled.
|
||||||
|
- Save → `update_vault_settings` called with `pending`; navigates back.
|
||||||
|
- Discard → no message sent; navigates back.
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
Additions in `popup/styles.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.settings-header h3 { margin: 0; font-size: 14px; }
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 14px; padding-top: 10px;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
.settings-section__title {
|
||||||
|
color: #8b949e; font-size: 10px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.settings-row {
|
||||||
|
display: grid; grid-template-columns: 110px 1fr;
|
||||||
|
gap: 6px 10px; align-items: center;
|
||||||
|
margin: 4px 0; font-size: 12px;
|
||||||
|
}
|
||||||
|
.settings-row__label { color: #8b949e; }
|
||||||
|
.settings-row select {
|
||||||
|
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||||||
|
padding: 3px 8px; border-radius: 3px; font: inherit; font-size: 11px;
|
||||||
|
}
|
||||||
|
.gen-preview-line {
|
||||||
|
margin: 0 0 6px; font-size: 11px; color: #c9d1d9;
|
||||||
|
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
.ack-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 4px 0; font-size: 11px;
|
||||||
|
border-bottom: 1px solid #161b22;
|
||||||
|
}
|
||||||
|
.ack-row__host { color: #c9d1d9; font-family: monospace; }
|
||||||
|
.ack-row__meta { color: #6e7681; font-size: 10px; }
|
||||||
|
.ack-row__revoke {
|
||||||
|
background: transparent; border: 0; color: #f85149;
|
||||||
|
cursor: pointer; font-size: 10px;
|
||||||
|
}
|
||||||
|
.settings-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 6px;
|
||||||
|
margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
No Rust changes. `cargo test --workspace` stays green (155 tests from β₁).
|
||||||
|
|
||||||
|
### Vitest
|
||||||
|
Existing 84 tests stay green. New tests:
|
||||||
|
- `types/__tests__/sections-render.test.ts` — ~5 tests.
|
||||||
|
- `types/__tests__/sections-edit.test.ts` (or per-type variants as appropriate) — ~5 tests.
|
||||||
|
- `__tests__/generator-popover.test.ts` — ~7 tests.
|
||||||
|
- `router/__tests__/router.test.ts` (extensions) — ~4 tests.
|
||||||
|
- `__tests__/settings-vault.test.ts` — ~5 tests.
|
||||||
|
|
||||||
|
Target post-β₂: ~110 tests.
|
||||||
|
|
||||||
|
### Manual matrix
|
||||||
|
|
||||||
|
1. Add a Login item; in the form's disclosure, add a section named "recovery codes" with two password fields; save; open detail → sections appear below typed rows; reveal works on each concealed row; copy works on text rows.
|
||||||
|
2. Edit the same item; remove one field; add a text field; save; detail reflects all three changes.
|
||||||
|
3. Click ⚙ → vault settings; change trash retention to `7 days`; save; reload → still `7 days`.
|
||||||
|
4. In vault settings, click "configure ▾" on the generator preview; change kind to BIP39; save as default; close popover; preview shows BIP39 summary. Reload → still BIP39.
|
||||||
|
5. Back on Login form, click "gen" → popover opens with BIP39 defaults (inherited from settings).
|
||||||
|
6. "use this value" on the popover fills the password field with a BIP39 phrase.
|
||||||
|
7. Revoke an origin ack; save; attempt autofill on that site → requires-ack flow re-triggers (per α's content-callable handler).
|
||||||
|
8. Kind toggle mid-popover switches Random ↔ BIP39; preview refreshes; request shape correct.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- `cargo test --workspace` green.
|
||||||
|
- `bun run test` green (~110 tests).
|
||||||
|
- `bun run build:all` green for Chrome + Firefox.
|
||||||
|
- `git grep -n '@ts-nocheck' extension/src/` → 0.
|
||||||
|
- `git grep -n 'coming-soon\|Coming in' extension/src/popup/components/ | grep -v document` → 0.
|
||||||
|
- Manual matrix 8 steps pass on both browsers.
|
||||||
|
|
||||||
|
## Open questions deferred to plan
|
||||||
|
|
||||||
|
- `generate_passphrase` message type: α shipped `generate_password`; if the message union lacks `generate_passphrase`, add it in Slice 4 alongside the vault-settings messages. The SW router just needs an additional case mirroring `generate_password`.
|
||||||
|
- Custom-field label blanks: what happens when a field has an empty `label`? Options: (a) reject at save time; (b) allow and render as "(unnamed)". Plan ships (b) — no UX friction; render the value row with the row's label span empty.
|
||||||
|
- Retention `custom…`: is the `prompt()` acceptable UX, or should it be an inline number + unit input? Plan ships `prompt()` (matches existing rename-section UX); can polish in a later pass.
|
||||||
|
- Deep-equal check for save-button enable: `JSON.stringify(a) === JSON.stringify(b)` is cheap and sufficient for the `VaultSettings` shape (no Map/Set/Date keys). Avoids a util dependency.
|
||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../popup', async () => {
|
||||||
|
const sendMessage = vi.fn();
|
||||||
|
return { sendMessage };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover';
|
||||||
|
import { sendMessage } from '../../popup';
|
||||||
|
import type { GeneratorRequest } from '../../../shared/types';
|
||||||
|
|
||||||
|
const DEFAULT_REQ: GeneratorRequest = {
|
||||||
|
kind: 'random',
|
||||||
|
length: 20,
|
||||||
|
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
symbol_charset: { kind: 'safe_only' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function setupAnchor(): HTMLElement {
|
||||||
|
document.body.innerHTML = '<button id="anchor">gen</button>';
|
||||||
|
return document.getElementById('anchor')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generator-popover', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(sendMessage).mockReset();
|
||||||
|
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { password: 'Kj7%pW@2xNq!8rMvT' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a popover with Random kind by default', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(document.querySelector('.generator-popover')).not.toBeNull();
|
||||||
|
expect(document.querySelector('#gen-kind-random')?.classList.contains('active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends generate_password on knob change (debounced)', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const slider = document.querySelector('#gen-length') as HTMLInputElement;
|
||||||
|
slider.value = '32';
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls.filter(
|
||||||
|
([msg]) => (msg as { type: string }).type === 'generate_password',
|
||||||
|
);
|
||||||
|
const latest = calls[calls.length - 1]![0] as { request: GeneratorRequest };
|
||||||
|
expect(latest.request.kind).toBe('random');
|
||||||
|
if (latest.request.kind === 'random') {
|
||||||
|
expect(latest.request.length).toBe(32);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BIP39 toggle swaps to generate_passphrase', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
(document.getElementById('gen-kind-bip39') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls;
|
||||||
|
expect(calls.some(([msg]) => (msg as { type: string }).type === 'generate_passphrase')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use-this-value invokes onPicked with current preview and closes', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
const onPicked = vi.fn();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
(document.querySelector('#gen-use') as HTMLButtonElement).click();
|
||||||
|
expect(onPicked).toHaveBeenCalledWith('Kj7%pW@2xNq!8rMvT');
|
||||||
|
expect(document.querySelector('.generator-popover')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save-as-default sends update_vault_settings with the current request', async () => {
|
||||||
|
vi.mocked(sendMessage).mockImplementation(async (msg: any) => {
|
||||||
|
if (msg.type === 'generate_password') return { ok: true, data: { password: 'abc' } };
|
||||||
|
if (msg.type === 'get_vault_settings') {
|
||||||
|
return { ok: true, data: { settings: {
|
||||||
|
trash_retention: { kind: 'days', value: 30 },
|
||||||
|
field_history_retention: { kind: 'forever' },
|
||||||
|
generator_defaults: DEFAULT_REQ,
|
||||||
|
attachment_caps: {},
|
||||||
|
autofill_origin_acks: {},
|
||||||
|
} } };
|
||||||
|
}
|
||||||
|
if (msg.type === 'update_vault_settings') return { ok: true };
|
||||||
|
return { ok: false, error: 'unhandled' };
|
||||||
|
});
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
(document.querySelector('#gen-save-default') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const updateCall = vi.mocked(sendMessage).mock.calls.find(
|
||||||
|
([m]) => (m as any).type === 'update_vault_settings',
|
||||||
|
);
|
||||||
|
expect(updateCall).toBeDefined();
|
||||||
|
const msg = updateCall![0] as { settings: { generator_defaults: GeneratorRequest } };
|
||||||
|
expect(msg.settings.generator_defaults.kind).toBe('random');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables use-button when no char class selected (Random)', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
for (const id of ['gen-lower', 'gen-upper', 'gen-digits', 'gen-symbols']) {
|
||||||
|
const cb = document.getElementById(id) as HTMLInputElement;
|
||||||
|
cb.checked = false;
|
||||||
|
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
const useBtn = document.querySelector('#gen-use') as HTMLButtonElement;
|
||||||
|
expect(useBtn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closeGeneratorPopover removes the DOM + handlers', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
closeGeneratorPopover();
|
||||||
|
expect(document.querySelector('.generator-popover')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
199
extension/src/popup/components/__tests__/sections-editor.test.ts
Normal file
199
extension/src/popup/components/__tests__/sections-editor.test.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { renderSectionsEditor, generateFieldId, wireSectionsEditor } from '../fields';
|
||||||
|
import type { Section } from '../../../shared/types';
|
||||||
|
|
||||||
|
describe('generateFieldId', () => {
|
||||||
|
it('returns 16 hex chars', () => {
|
||||||
|
const id = generateFieldId();
|
||||||
|
expect(id).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
it('returns unique values on successive calls', () => {
|
||||||
|
const ids = new Set(Array.from({ length: 50 }, () => generateFieldId()));
|
||||||
|
expect(ids.size).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderSectionsEditor', () => {
|
||||||
|
it('shows the disclosure toggle with the correct count', () => {
|
||||||
|
const sections: Section[] = [
|
||||||
|
{ name: 'a', fields: [
|
||||||
|
{ id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false },
|
||||||
|
{ id: 'f1', label: 'l', kind: 'password', value: { kind: 'password', value: 'p' }, hidden_by_default: true },
|
||||||
|
] },
|
||||||
|
{ fields: [
|
||||||
|
{ id: 'f2', label: 'l', kind: 'concealed', value: { kind: 'concealed', value: 'c' }, hidden_by_default: true },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
const html = renderSectionsEditor(sections, false);
|
||||||
|
expect(html).toContain('2 sections');
|
||||||
|
expect(html).toContain('3 fields');
|
||||||
|
expect(html).toContain('data-expanded="false"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows singular "1 section / 1 field" when applicable', () => {
|
||||||
|
const sections: Section[] = [
|
||||||
|
{ name: 'only', fields: [
|
||||||
|
{ id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
const html = renderSectionsEditor(sections, false);
|
||||||
|
expect(html).toContain('1 section');
|
||||||
|
expect(html).toContain('1 field');
|
||||||
|
expect(html).not.toContain('1 sections');
|
||||||
|
expect(html).not.toContain('1 fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders expanded body when expanded=true', () => {
|
||||||
|
const html = renderSectionsEditor([], true);
|
||||||
|
expect(html).toContain('data-expanded="true"');
|
||||||
|
expect(html).toContain('add section');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wireSectionsEditor', () => {
|
||||||
|
it('toggle click flips data-expanded', () => {
|
||||||
|
document.body.innerHTML = renderSectionsEditor([], false);
|
||||||
|
const sections: Section[] = [];
|
||||||
|
const rerender = vi.fn();
|
||||||
|
wireSectionsEditor(document.body, sections, rerender);
|
||||||
|
const toggle = document.querySelector('.disclosure__toggle') as HTMLButtonElement;
|
||||||
|
toggle.click();
|
||||||
|
const disclosure = document.querySelector('.disclosure') as HTMLElement;
|
||||||
|
expect(disclosure.getAttribute('data-expanded')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add-section click appends an empty section', () => {
|
||||||
|
const sections: Section[] = [];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
const rerender = vi.fn();
|
||||||
|
wireSectionsEditor(document.body, sections, rerender);
|
||||||
|
const addBtn = document.querySelector('.add-section') as HTMLButtonElement;
|
||||||
|
addBtn.click();
|
||||||
|
expect(sections).toHaveLength(1);
|
||||||
|
expect(sections[0]).toEqual({ name: undefined, fields: [] });
|
||||||
|
expect(rerender).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add-text-field click on a section pushes a text field', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
const rerender = vi.fn();
|
||||||
|
wireSectionsEditor(document.body, sections, rerender);
|
||||||
|
const addText = document.querySelector('[data-add-field="text"][data-section-idx="0"]') as HTMLButtonElement;
|
||||||
|
addText.click();
|
||||||
|
expect(sections[0].fields).toHaveLength(1);
|
||||||
|
expect(sections[0].fields[0].kind).toBe('text');
|
||||||
|
expect(sections[0].fields[0].value.kind).toBe('text');
|
||||||
|
expect(sections[0].fields[0].value.value).toBe('');
|
||||||
|
expect(sections[0].fields[0].hidden_by_default).toBe(false);
|
||||||
|
expect(sections[0].fields[0].id).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add-password-field sets hidden_by_default=true', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
(document.querySelector('[data-add-field="password"][data-section-idx="0"]') as HTMLButtonElement).click();
|
||||||
|
expect(sections[0].fields[0].hidden_by_default).toBe(true);
|
||||||
|
expect(sections[0].fields[0].kind).toBe('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove-field button splices field', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [
|
||||||
|
{ id: 'f0', label: 'a', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false },
|
||||||
|
{ id: 'f1', label: 'b', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false },
|
||||||
|
] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
const deleteBtn = document.querySelector('[data-delete-field="f0"]') as HTMLButtonElement;
|
||||||
|
deleteBtn.click();
|
||||||
|
expect(sections[0].fields).toHaveLength(1);
|
||||||
|
expect(sections[0].fields[0].id).toBe('f1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove-section button splices section (after confirm)', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
const sections: Section[] = [
|
||||||
|
{ name: 'to-remove', fields: [] },
|
||||||
|
{ name: 'keep', fields: [] },
|
||||||
|
];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
(document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click();
|
||||||
|
expect(sections).toHaveLength(1);
|
||||||
|
expect(sections[0].name).toBe('keep');
|
||||||
|
confirmSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove-section cancelled confirm leaves section intact', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
const sections: Section[] = [{ name: 'stays', fields: [] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
(document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click();
|
||||||
|
expect(sections).toHaveLength(1);
|
||||||
|
confirmSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('label input change mutates section field label in place (no rerender)', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [
|
||||||
|
{ id: 'f0', label: 'old', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false },
|
||||||
|
] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
const rerender = vi.fn();
|
||||||
|
wireSectionsEditor(document.body, sections, rerender);
|
||||||
|
const labelInput = document.querySelector('[data-field-label="f0"]') as HTMLInputElement;
|
||||||
|
labelInput.value = 'new';
|
||||||
|
labelInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
expect(sections[0].fields[0].label).toBe('new');
|
||||||
|
expect(rerender).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('value input change mutates section field value in place', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [
|
||||||
|
{ id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'old' }, hidden_by_default: false },
|
||||||
|
] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
const valueInput = document.querySelector('[data-field-value-input="f0"]') as HTMLInputElement;
|
||||||
|
valueInput.value = 'new';
|
||||||
|
valueInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
expect(sections[0].fields[0].value).toEqual({ kind: 'text', value: 'new' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wireSectionsEditor preserves unsupported-kind fields on save', () => {
|
||||||
|
it('renders preserved note when section contains unsupported-kind fields', () => {
|
||||||
|
const sections: Section[] = [{
|
||||||
|
name: 'mixed',
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000001', label: 'note', kind: 'text',
|
||||||
|
value: { kind: 'text', value: 'ok' }, hidden_by_default: false },
|
||||||
|
{ id: 'f0000002', label: 'when', kind: 'date' as any,
|
||||||
|
value: { kind: 'date', value: '2026-01-01' } as any, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
expect(document.body.innerHTML).toContain('1 field of unsupported kind');
|
||||||
|
expect(document.body.innerHTML).not.toContain('f0000002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add-text then save does not destroy unsupported-kind fields', () => {
|
||||||
|
const sections: Section[] = [{
|
||||||
|
name: 'mixed',
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000002', label: 'when', kind: 'date' as any,
|
||||||
|
value: { kind: 'date', value: '2026-01-01' } as any, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
const addText = document.querySelector('[data-add-field="text"][data-section-idx="0"]') as HTMLButtonElement;
|
||||||
|
addText.click();
|
||||||
|
expect(sections[0].fields).toHaveLength(2);
|
||||||
|
// Unsupported-kind field preserved untouched.
|
||||||
|
const dateField = sections[0].fields.find((f) => f.id === 'f0000002');
|
||||||
|
expect(dateField).toBeDefined();
|
||||||
|
expect(dateField!.value).toEqual({ kind: 'date', value: '2026-01-01' });
|
||||||
|
});
|
||||||
|
});
|
||||||
104
extension/src/popup/components/__tests__/sections-render.test.ts
Normal file
104
extension/src/popup/components/__tests__/sections-render.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { renderSections } from '../fields';
|
||||||
|
import type { Item } from '../../../shared/types';
|
||||||
|
|
||||||
|
function itemWithSections(sections: Item['sections']): Item {
|
||||||
|
return {
|
||||||
|
id: 'aaaaaaaaaaaaaaaa',
|
||||||
|
title: 'test',
|
||||||
|
type: 'login',
|
||||||
|
tags: [], favorite: false,
|
||||||
|
created: 0, modified: 0,
|
||||||
|
core: { type: 'login' },
|
||||||
|
sections,
|
||||||
|
attachments: [],
|
||||||
|
field_history: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderSections', () => {
|
||||||
|
it('returns empty string when item has no sections', () => {
|
||||||
|
const html = renderSections(itemWithSections([]), 'login');
|
||||||
|
expect(html).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips sections with zero fields', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{ name: 'empty', fields: [] },
|
||||||
|
]), 'login');
|
||||||
|
expect(html).not.toContain('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a named section header + field rows', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
name: 'recovery codes',
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000001', label: 'code 1', kind: 'text',
|
||||||
|
value: { kind: 'text', value: 'abc-123' }, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).toContain('recovery codes');
|
||||||
|
expect(html).toContain('code 1');
|
||||||
|
expect(html).toContain('abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders concealed password fields with unique ids', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
name: 'backup',
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000002', label: 'pin', kind: 'password',
|
||||||
|
value: { kind: 'password', value: 'hunter2' }, hidden_by_default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).toContain('data-field-id="login-s0-f0"');
|
||||||
|
expect(html).toContain('data-revealed="false"');
|
||||||
|
expect(html).not.toMatch(/>hunter2</);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders anonymous section with separator not header', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000003', label: 'extra', kind: 'text',
|
||||||
|
value: { kind: 'text', value: 'note' }, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).toContain('section-separator');
|
||||||
|
expect(html).not.toContain('section-header');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silently skips unsupported field kinds', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000004', label: 'link', kind: 'url' as any,
|
||||||
|
value: { kind: 'url', value: 'https://example.com' } as any,
|
||||||
|
hidden_by_default: false },
|
||||||
|
{ id: 'f0000005', label: 'note', kind: 'text',
|
||||||
|
value: { kind: 'text', value: 'kept' }, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).not.toContain('https://example.com');
|
||||||
|
expect(html).toContain('kept');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders concealed fields for the concealed kind too', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000006', label: 'secret', kind: 'concealed',
|
||||||
|
value: { kind: 'concealed', value: 'shhh' }, hidden_by_default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).toContain('data-field-id="login-s0-f0"');
|
||||||
|
expect(html).toContain('secret');
|
||||||
|
expect(html).not.toMatch(/>shhh</);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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: 'settings-vault',
|
||||||
|
entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
|
||||||
|
searchQuery: '', activeGroup: null, error: null, loading: false,
|
||||||
|
capturedTabId: null, capturedUrl: '', newType: null,
|
||||||
|
vaultSettings: {
|
||||||
|
trash_retention: { kind: 'days', value: 30 },
|
||||||
|
field_history_retention: { kind: 'forever' },
|
||||||
|
generator_defaults: {
|
||||||
|
kind: 'random', length: 20,
|
||||||
|
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
symbol_charset: { kind: 'safe_only' },
|
||||||
|
},
|
||||||
|
attachment_caps: {},
|
||||||
|
autofill_origin_acks: { 'github.com': 1000000000, 'example.com': 999000000 },
|
||||||
|
},
|
||||||
|
generatorDefaults: null,
|
||||||
|
}));
|
||||||
|
const escapeHtml = (s: string) => s
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../generator-popover', () => ({
|
||||||
|
openGeneratorPopover: vi.fn(),
|
||||||
|
closeGeneratorPopover: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { renderVaultSettings } from '../settings-vault';
|
||||||
|
import { sendMessage } from '../../popup';
|
||||||
|
|
||||||
|
describe('settings-vault', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
vi.mocked(sendMessage).mockReset();
|
||||||
|
vi.mocked(sendMessage).mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with seeded vault-settings values', () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
expect(app.textContent).toContain('vault settings');
|
||||||
|
expect(app.textContent).toContain('github.com');
|
||||||
|
expect(app.textContent).toContain('example.com');
|
||||||
|
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
|
||||||
|
expect(trashSel.value).toBe('days:30');
|
||||||
|
const histSel = document.getElementById('history-retention') as HTMLSelectElement;
|
||||||
|
expect(histSel.value).toBe('forever');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders origin acks sorted by recency (descending)', () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
const rows = Array.from(document.querySelectorAll('.ack-row__host')).map((e) => e.textContent);
|
||||||
|
expect(rows).toEqual(['github.com', 'example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save button disabled until a change is made', () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
const saveBtn = document.getElementById('save-btn') as HTMLButtonElement;
|
||||||
|
expect(saveBtn.disabled).toBe(true);
|
||||||
|
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
|
||||||
|
trashSel.value = 'forever';
|
||||||
|
trashSel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
expect(saveBtn.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revoke button removes origin from pending and enables save', () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
(document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click();
|
||||||
|
expect(document.querySelector('[data-revoke="github.com"]')).toBeNull();
|
||||||
|
expect((document.getElementById('save-btn') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save button triggers update_vault_settings with pending', async () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
(document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click();
|
||||||
|
(document.getElementById('save-btn') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const call = vi.mocked(sendMessage).mock.calls.find(
|
||||||
|
([m]) => (m as any).type === 'update_vault_settings',
|
||||||
|
);
|
||||||
|
expect(call).toBeDefined();
|
||||||
|
const payload = call![0] as { settings: any };
|
||||||
|
expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com');
|
||||||
|
expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
354
extension/src/popup/components/fields.ts
Normal file
354
extension/src/popup/components/fields.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
/// 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';
|
||||||
|
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an Item's sections as read-only field rows. Each section with
|
||||||
|
/// ≥1 field emits a header (if named) or thin separator (if anonymous)
|
||||||
|
/// plus field rows via renderRow / renderConcealedRow. Sections with
|
||||||
|
/// 0 fields are skipped. Fields with unsupported kinds are silently
|
||||||
|
/// skipped (β₂ supports text, password, concealed only).
|
||||||
|
///
|
||||||
|
/// `idPrefix` uniquifies concealed-row IDs (`${idPrefix}-s{i}-f{j}`)
|
||||||
|
/// so multiple typed-item detail views rendered in sequence don't
|
||||||
|
/// collide on wireFieldHandlers lookups.
|
||||||
|
export function renderSections(item: Item, idPrefix: string): string {
|
||||||
|
let out = '';
|
||||||
|
item.sections.forEach((section, sIdx) => {
|
||||||
|
const visibleFields = section.fields.filter(
|
||||||
|
(f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed',
|
||||||
|
);
|
||||||
|
if (visibleFields.length === 0) return;
|
||||||
|
|
||||||
|
if (section.name) {
|
||||||
|
out += `<div class="section-header">${escapeHtml(section.name)}</div>`;
|
||||||
|
} else {
|
||||||
|
out += `<hr class="section-separator">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleFields.forEach((field, fIdx) => {
|
||||||
|
if (field.value.kind === 'text') {
|
||||||
|
out += renderRow({ label: field.label, value: field.value.value, copyable: true });
|
||||||
|
} else if (field.value.kind === 'password' || field.value.kind === 'concealed') {
|
||||||
|
out += renderConcealedRow({
|
||||||
|
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
||||||
|
label: field.label,
|
||||||
|
value: field.value.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 16-char hex FieldId. crypto.getRandomValues for 8 bytes.
|
||||||
|
export function generateFieldId(): string {
|
||||||
|
const bytes = new Uint8Array(8);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeField(kind: 'text' | 'password' | 'concealed'): Field {
|
||||||
|
const value: FieldValue = { kind, value: '' };
|
||||||
|
return {
|
||||||
|
id: generateFieldId(),
|
||||||
|
label: 'new field',
|
||||||
|
kind,
|
||||||
|
value,
|
||||||
|
hidden_by_default: kind !== 'text',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the collapsible custom-sections editor. Returns HTML for the
|
||||||
|
/// disclosure toggle + body. The expanded-state is owned externally
|
||||||
|
/// (via a module-scope flag in the caller); this helper reads it as
|
||||||
|
/// the `expanded` parameter.
|
||||||
|
export function renderSectionsEditor(sections: Section[], expanded: boolean): string {
|
||||||
|
const sectionCount = sections.length;
|
||||||
|
const fieldCount = sections.reduce((sum, s) => sum + s.fields.length, 0);
|
||||||
|
const sectionLabel = sectionCount === 1 ? '1 section' : `${sectionCount} sections`;
|
||||||
|
const fieldLabel = fieldCount === 1 ? '1 field' : `${fieldCount} fields`;
|
||||||
|
const summary = sectionCount === 0 && fieldCount === 0
|
||||||
|
? 'no custom fields'
|
||||||
|
: `${sectionLabel}, ${fieldLabel}`;
|
||||||
|
|
||||||
|
const body = sections.map((section, sIdx) => renderSectionBlock(section, sIdx)).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="disclosure" data-expanded="${expanded ? 'true' : 'false'}">
|
||||||
|
<button type="button" class="disclosure__toggle">▾ custom sections & fields (${escapeHtml(summary)})</button>
|
||||||
|
<div class="disclosure__body">
|
||||||
|
${body}
|
||||||
|
<button type="button" class="add-section">+ add section</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSectionBlock(section: Section, sIdx: number): string {
|
||||||
|
const nameDisplay = section.name
|
||||||
|
? `<span class="name">${escapeHtml(section.name)}</span>`
|
||||||
|
: `<span class="name anon">(anonymous)</span>`;
|
||||||
|
|
||||||
|
// Only render supported kinds. Other-kind fields stay in sectionsDraft
|
||||||
|
// untouched so they survive save intact.
|
||||||
|
const editable = section.fields.filter(
|
||||||
|
(f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed',
|
||||||
|
);
|
||||||
|
const fieldsHtml = editable.map((f) => renderEditorField(f, sIdx, 0)).join('');
|
||||||
|
|
||||||
|
const preservedCount = section.fields.length - editable.length;
|
||||||
|
const preservedNote = preservedCount > 0
|
||||||
|
? `<div class="section-editor__preserved">${preservedCount} field${preservedCount === 1 ? '' : 's'} of unsupported kind (edit via CLI)</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="section-editor" data-section-idx="${sIdx}">
|
||||||
|
<div class="section-editor__head">
|
||||||
|
${nameDisplay}
|
||||||
|
<span class="actions">
|
||||||
|
<button type="button" data-rename-section="${sIdx}">rename</button>
|
||||||
|
<button type="button" data-remove-section="${sIdx}">× remove section</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${fieldsHtml}
|
||||||
|
${preservedNote}
|
||||||
|
<div class="section-editor__add">
|
||||||
|
<button type="button" data-add-field="text" data-section-idx="${sIdx}">+ text</button>
|
||||||
|
<button type="button" data-add-field="password" data-section-idx="${sIdx}">+ password</button>
|
||||||
|
<button type="button" data-add-field="concealed" data-section-idx="${sIdx}">+ concealed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditorField(field: Field, sIdx: number, _fIdx: number): string {
|
||||||
|
const valueStr = (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed')
|
||||||
|
? field.value.value
|
||||||
|
: '';
|
||||||
|
const inputType = field.value.kind === 'text' ? 'text' : 'password';
|
||||||
|
return `
|
||||||
|
<div class="section-editor__field">
|
||||||
|
<input type="text" data-field-label="${escapeHtml(field.id)}" value="${escapeHtml(field.label)}" placeholder="label">
|
||||||
|
<input type="${inputType}" data-field-value-input="${escapeHtml(field.id)}" value="${escapeHtml(valueStr)}" placeholder="value">
|
||||||
|
<button type="button" class="delete-field" data-delete-field="${escapeHtml(field.id)}" data-section-idx="${sIdx}">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findField(
|
||||||
|
sectionsDraft: Section[],
|
||||||
|
fieldId: string,
|
||||||
|
): { section: Section; fieldIdx: number } | null {
|
||||||
|
for (const section of sectionsDraft) {
|
||||||
|
const idx = section.fields.findIndex((f) => f.id === fieldId);
|
||||||
|
if (idx >= 0) return { section, fieldIdx: idx };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire click + input handlers on a rendered sections-editor. Mutations
|
||||||
|
/// happen in place on `sectionsDraft`. `rerender` is called after any
|
||||||
|
/// structural change (add/remove) to regenerate the disclosure body;
|
||||||
|
/// label/value edits do NOT trigger rerender (would steal focus).
|
||||||
|
export function wireSectionsEditor(
|
||||||
|
scope: HTMLElement,
|
||||||
|
sectionsDraft: Section[],
|
||||||
|
rerender: () => void,
|
||||||
|
): void {
|
||||||
|
const toggle = scope.querySelector('.disclosure__toggle') as HTMLButtonElement | null;
|
||||||
|
toggle?.addEventListener('click', () => {
|
||||||
|
const disclosure = scope.querySelector('.disclosure') as HTMLElement | null;
|
||||||
|
if (!disclosure) return;
|
||||||
|
const expanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
disclosure.setAttribute('data-expanded', expanded ? 'false' : 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelector('.add-section')?.addEventListener('click', () => {
|
||||||
|
sectionsDraft.push({ name: undefined, fields: [] });
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-rename-section]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const sIdx = Number(btn.dataset.renameSection);
|
||||||
|
const current = sectionsDraft[sIdx]?.name ?? '';
|
||||||
|
const name = window.prompt('Section name (empty for none):', current);
|
||||||
|
if (name === null) return;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
sectionsDraft[sIdx].name = trimmed || undefined;
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-remove-section]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const sIdx = Number(btn.dataset.removeSection);
|
||||||
|
const name = sectionsDraft[sIdx]?.name ?? '(anonymous)';
|
||||||
|
if (!window.confirm(`Remove section "${name}" and all its fields?`)) return;
|
||||||
|
sectionsDraft.splice(sIdx, 1);
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-add-field]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const sIdx = Number(btn.dataset.sectionIdx);
|
||||||
|
const kind = btn.dataset.addField as 'text' | 'password' | 'concealed';
|
||||||
|
sectionsDraft[sIdx].fields.push(makeField(kind));
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-delete-field]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const fieldId = btn.dataset.deleteField ?? '';
|
||||||
|
const found = findField(sectionsDraft, fieldId);
|
||||||
|
if (!found) return;
|
||||||
|
found.section.fields = found.section.fields.filter((f) => f.id !== fieldId);
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLInputElement>('[data-field-label]').forEach((input) => {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const fieldId = input.dataset.fieldLabel ?? '';
|
||||||
|
const found = findField(sectionsDraft, fieldId);
|
||||||
|
if (found) {
|
||||||
|
found.section.fields[found.fieldIdx].label = input.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLInputElement>('[data-field-value-input]').forEach((input) => {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const fieldId = input.dataset.fieldValueInput ?? '';
|
||||||
|
const found = findField(sectionsDraft, fieldId);
|
||||||
|
if (!found) return;
|
||||||
|
const field = found.section.fields[found.fieldIdx];
|
||||||
|
// Only mutate supported kinds. Unsupported kinds are never rendered
|
||||||
|
// as editable (filtered by renderSectionBlock), so this path shouldn't
|
||||||
|
// fire for them — but guard defensively.
|
||||||
|
if (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed') {
|
||||||
|
const kind = field.value.kind;
|
||||||
|
field.value = { kind, value: input.value };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
350
extension/src/popup/components/generator-popover.ts
Normal file
350
extension/src/popup/components/generator-popover.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
/// Inline generator popover — anchored to a "gen" button, renders a
|
||||||
|
/// live preview that updates as knobs change (150ms debounce). Single
|
||||||
|
/// underlying GeneratorRequest; kind toggle swaps between Random +
|
||||||
|
/// BIP39 knob sets. Actions: use / save-as-default / reset / cancel.
|
||||||
|
|
||||||
|
import { sendMessage } from '../popup';
|
||||||
|
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
||||||
|
|
||||||
|
interface UiKnobs {
|
||||||
|
kind: 'random' | 'bip39';
|
||||||
|
// Random
|
||||||
|
length: number;
|
||||||
|
lower: boolean;
|
||||||
|
upper: boolean;
|
||||||
|
digits: boolean;
|
||||||
|
symbols: boolean;
|
||||||
|
symbolCharset: 'safe_only' | 'extended' | 'custom';
|
||||||
|
customSymbols: string;
|
||||||
|
// BIP39
|
||||||
|
wordCount: number;
|
||||||
|
separator: string;
|
||||||
|
capitalization: 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function knobsFromRequest(req: GeneratorRequest): UiKnobs {
|
||||||
|
const defaults: UiKnobs = {
|
||||||
|
kind: 'random',
|
||||||
|
length: 20, lower: true, upper: true, digits: true, symbols: true,
|
||||||
|
symbolCharset: 'safe_only', customSymbols: '',
|
||||||
|
wordCount: 5, separator: ' ', capitalization: 'lower',
|
||||||
|
};
|
||||||
|
if (req.kind === 'random') {
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
kind: 'random',
|
||||||
|
length: req.length,
|
||||||
|
lower: req.classes.lower,
|
||||||
|
upper: req.classes.upper,
|
||||||
|
digits: req.classes.digits,
|
||||||
|
symbols: req.classes.symbols,
|
||||||
|
symbolCharset: req.symbol_charset.kind,
|
||||||
|
customSymbols: req.symbol_charset.kind === 'custom' ? req.symbol_charset.value : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
kind: 'bip39',
|
||||||
|
wordCount: req.word_count,
|
||||||
|
separator: req.separator,
|
||||||
|
capitalization: req.capitalization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestFromKnobs(knobs: UiKnobs): GeneratorRequest {
|
||||||
|
if (knobs.kind === 'random') {
|
||||||
|
return {
|
||||||
|
kind: 'random',
|
||||||
|
length: knobs.length,
|
||||||
|
classes: {
|
||||||
|
lower: knobs.lower, upper: knobs.upper,
|
||||||
|
digits: knobs.digits, symbols: knobs.symbols,
|
||||||
|
},
|
||||||
|
symbol_charset:
|
||||||
|
knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } :
|
||||||
|
knobs.symbolCharset === 'extended' ? { kind: 'extended' } :
|
||||||
|
{ kind: 'custom', value: knobs.customSymbols },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'bip39',
|
||||||
|
word_count: knobs.wordCount,
|
||||||
|
separator: knobs.separator,
|
||||||
|
capitalization: knobs.capitalization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let activePopover: {
|
||||||
|
host: HTMLElement;
|
||||||
|
cleanup: () => void;
|
||||||
|
} | null = null;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export interface OpenPopoverOpts {
|
||||||
|
anchor: HTMLElement;
|
||||||
|
initial: GeneratorRequest;
|
||||||
|
onPicked: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openGeneratorPopover(opts: OpenPopoverOpts): void {
|
||||||
|
closeGeneratorPopover();
|
||||||
|
|
||||||
|
const knobs = knobsFromRequest(opts.initial);
|
||||||
|
let currentPreview = '';
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.className = 'generator-popover';
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
// Position below anchor
|
||||||
|
const rect = opts.anchor.getBoundingClientRect();
|
||||||
|
host.style.top = `${rect.bottom + 6}px`;
|
||||||
|
host.style.left = `${rect.left}px`;
|
||||||
|
|
||||||
|
const render = (): void => {
|
||||||
|
host.innerHTML = buildInnerHtml(knobs);
|
||||||
|
wireInner();
|
||||||
|
refreshPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPreview = (): void => {
|
||||||
|
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
debounceTimer = null;
|
||||||
|
const request = requestFromKnobs(knobs);
|
||||||
|
const msg = knobs.kind === 'random'
|
||||||
|
? { type: 'generate_password' as const, request }
|
||||||
|
: { type: 'generate_passphrase' as const, request };
|
||||||
|
const resp = await sendMessage(msg);
|
||||||
|
if (resp.ok) {
|
||||||
|
const d = resp.data as { password?: string; passphrase?: string };
|
||||||
|
currentPreview = d.password ?? d.passphrase ?? '';
|
||||||
|
const el = host.querySelector('.gen-preview__value');
|
||||||
|
if (el) el.textContent = currentPreview;
|
||||||
|
updateValidation();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateValidation = (): void => {
|
||||||
|
const useBtn = host.querySelector('#gen-use') as HTMLButtonElement | null;
|
||||||
|
if (!useBtn) return;
|
||||||
|
const noClass = knobs.kind === 'random'
|
||||||
|
&& !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols);
|
||||||
|
useBtn.disabled = noClass;
|
||||||
|
const note = host.querySelector('.gen-validation');
|
||||||
|
if (note) (note as HTMLElement).style.display = noClass ? 'block' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const wireInner = (): void => {
|
||||||
|
host.querySelector('#gen-kind-random')?.addEventListener('click', () => {
|
||||||
|
knobs.kind = 'random'; render();
|
||||||
|
});
|
||||||
|
host.querySelector('#gen-kind-bip39')?.addEventListener('click', () => {
|
||||||
|
knobs.kind = 'bip39'; render();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-length')?.addEventListener('input', (e) => {
|
||||||
|
knobs.length = Number((e.target as HTMLInputElement).value);
|
||||||
|
const out = host.querySelector('#gen-length-val');
|
||||||
|
if (out) out.textContent = String(knobs.length);
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { id, key } of [
|
||||||
|
{ id: 'gen-lower', key: 'lower' as const },
|
||||||
|
{ id: 'gen-upper', key: 'upper' as const },
|
||||||
|
{ id: 'gen-digits', key: 'digits' as const },
|
||||||
|
{ id: 'gen-symbols', key: 'symbols' as const },
|
||||||
|
]) {
|
||||||
|
host.querySelector(`#${id}`)?.addEventListener('change', (e) => {
|
||||||
|
knobs[key] = (e.target as HTMLInputElement).checked;
|
||||||
|
updateValidation();
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('[data-symbol-charset]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
knobs.symbolCharset = btn.dataset.symbolCharset as UiKnobs['symbolCharset'];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-word-count')?.addEventListener('input', (e) => {
|
||||||
|
knobs.wordCount = Number((e.target as HTMLInputElement).value);
|
||||||
|
const out = host.querySelector('#gen-word-count-val');
|
||||||
|
if (out) out.textContent = String(knobs.wordCount);
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('[data-separator]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
knobs.separator = btn.dataset.separator ?? ' ';
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('[data-capitalization]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
knobs.capitalization = btn.dataset.capitalization as UiKnobs['capitalization'];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('.gen-preview__regen')?.addEventListener('click', () => {
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-use')?.addEventListener('click', () => {
|
||||||
|
opts.onPicked(currentPreview);
|
||||||
|
closeGeneratorPopover();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-save-default')?.addEventListener('click', async () => {
|
||||||
|
const getResp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (!getResp.ok) return;
|
||||||
|
const vs = (getResp.data as { settings: VaultSettings }).settings;
|
||||||
|
const updated: VaultSettings = { ...vs, generator_defaults: requestFromKnobs(knobs) };
|
||||||
|
await sendMessage({ type: 'update_vault_settings', settings: updated });
|
||||||
|
const btn = host.querySelector('#gen-save-default') as HTMLButtonElement | null;
|
||||||
|
if (btn) {
|
||||||
|
const original = btn.textContent;
|
||||||
|
btn.textContent = 'saved';
|
||||||
|
setTimeout(() => { if (btn.textContent === 'saved') btn.textContent = original; }, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-reset')?.addEventListener('click', async () => {
|
||||||
|
const getResp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (!getResp.ok) return;
|
||||||
|
const vs = (getResp.data as { settings: VaultSettings }).settings;
|
||||||
|
Object.assign(knobs, knobsFromRequest(vs.generator_defaults));
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-cancel')?.addEventListener('click', () => closeGeneratorPopover());
|
||||||
|
host.querySelector('#gen-close')?.addEventListener('click', () => closeGeneratorPopover());
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOutsideClick = (e: MouseEvent) => {
|
||||||
|
if (!host.contains(e.target as Node) && e.target !== opts.anchor) {
|
||||||
|
closeGeneratorPopover();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeGeneratorPopover();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
document.removeEventListener('click', onOutsideClick, true);
|
||||||
|
document.removeEventListener('keydown', onEsc);
|
||||||
|
host.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
activePopover = { host, cleanup };
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', onOutsideClick, true);
|
||||||
|
document.addEventListener('keydown', onEsc);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeGeneratorPopover(): void {
|
||||||
|
if (activePopover === null) return;
|
||||||
|
if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; }
|
||||||
|
activePopover.cleanup();
|
||||||
|
activePopover = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML builders ---
|
||||||
|
|
||||||
|
function buildInnerHtml(knobs: UiKnobs): string {
|
||||||
|
return `
|
||||||
|
<div class="gen-header">
|
||||||
|
<span class="gen-title">generate</span>
|
||||||
|
<button type="button" id="gen-close" class="gen-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">kind</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
<button id="gen-kind-random" class="${knobs.kind === 'random' ? 'active' : ''}">Random</button>
|
||||||
|
<button id="gen-kind-bip39" class="${knobs.kind === 'bip39' ? 'active' : ''}">BIP39</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
|
||||||
|
<div class="gen-preview">
|
||||||
|
<span class="gen-preview__value"></span>
|
||||||
|
<button type="button" class="gen-preview__regen" title="regenerate">↻</button>
|
||||||
|
</div>
|
||||||
|
${knobs.kind === 'random'
|
||||||
|
? `<p class="gen-validation" style="display:none;color:#f85149;font-size:10px;margin:4px 0 0;">pick at least one character class</p>`
|
||||||
|
: ''}
|
||||||
|
<div class="gen-actions">
|
||||||
|
<button type="button" class="btn" id="gen-reset">reset to defaults</button>
|
||||||
|
<button type="button" class="btn" id="gen-save-default">save as default</button>
|
||||||
|
<button type="button" class="btn" id="gen-cancel">cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="gen-use">use this value</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRandomKnobs(k: UiKnobs): string {
|
||||||
|
return `
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">length</span>
|
||||||
|
<input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="gen-slider">
|
||||||
|
<span id="gen-length-val">${k.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gen-check-grid">
|
||||||
|
<label><input type="checkbox" id="gen-lower" ${k.lower ? 'checked' : ''}> lowercase</label>
|
||||||
|
<label><input type="checkbox" id="gen-digits" ${k.digits ? 'checked' : ''}> digits</label>
|
||||||
|
<label><input type="checkbox" id="gen-upper" ${k.upper ? 'checked' : ''}> uppercase</label>
|
||||||
|
<label><input type="checkbox" id="gen-symbols" ${k.symbols ? 'checked' : ''}> symbols</label>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">symbols</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
<button data-symbol-charset="safe_only" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button>
|
||||||
|
<button data-symbol-charset="extended" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBip39Knobs(k: UiKnobs): string {
|
||||||
|
const sepChip = (label: string, sep: string) => `
|
||||||
|
<button data-separator="${sep}" class="${k.separator === sep ? 'active' : ''}">${label}</button>
|
||||||
|
`;
|
||||||
|
const capChip = (label: string, val: string) => `
|
||||||
|
<button data-capitalization="${val}" class="${k.capitalization === val ? 'active' : ''}">${label}</button>
|
||||||
|
`;
|
||||||
|
return `
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">words</span>
|
||||||
|
<input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="gen-slider">
|
||||||
|
<span id="gen-word-count-val">${k.wordCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">separator</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
${sepChip('space', ' ')}
|
||||||
|
${sepChip('-', '-')}
|
||||||
|
${sepChip('_', '_')}
|
||||||
|
${sepChip('.', '.')}
|
||||||
|
${sepChip(':', ':')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">case</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
${capChip('lower', 'lower')}
|
||||||
|
${capChip('upper', 'upper')}
|
||||||
|
${capChip('first', 'first_of_each')}
|
||||||
|
${capChip('title', 'title')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -1,374 +1,48 @@
|
|||||||
/// Typed-item detail view — dispatches on `item.type`. Slice 6 delivers
|
/// Typed-item detail view dispatcher. Each type's renderDetail lives in
|
||||||
/// full Login parity; all other types show a "coming soon" placeholder.
|
/// its own module under ./types/. Document stays "coming soon" until γ.
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
import { navigate } from '../popup';
|
||||||
import type { Item, ItemId, ManifestEntry, LoginCore, TotpConfig } from '../../shared/types';
|
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 {
|
const item = getState().selectedItem;
|
||||||
if (totpInterval !== null) {
|
if (!item) { navigate('list'); return; }
|
||||||
clearInterval(totpInterval);
|
|
||||||
totpInterval = null;
|
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 {
|
function renderComingSoon(app: HTMLElement, item: Item): void {
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="detail-header">
|
<div class="pad">
|
||||||
<span class="detail-title">${escapeHtml(item.title)}</span>
|
<div class="detail-title" style="margin-bottom:16px;">${item.title}</div>
|
||||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
<p class="muted">The <strong>${item.type}</strong> item type is not editable in the extension yet.</p>
|
||||||
</div>
|
<div class="form-actions"><button class="btn" id="back-btn">back</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,281 +1,44 @@
|
|||||||
/// Typed-item add/edit form. Slice 6 ships full Login parity; other
|
/// Typed-item add/edit form dispatcher. Each type's renderForm lives in
|
||||||
/// types show a coming-soon placeholder (use the CLI for now).
|
/// its own module under ./types/. Document stays "coming soon" until γ.
|
||||||
///
|
|
||||||
/// 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.)
|
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
import { navigate, getState } from '../popup';
|
||||||
import type {
|
import type { Item, ItemType } from '../../shared/types';
|
||||||
Item, ItemId, ItemType, ManifestEntry, LoginCore, TotpConfig,
|
import * as login from './types/login';
|
||||||
} from '../../shared/types';
|
import * as secureNote from './types/secure-note';
|
||||||
import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types';
|
import * as identity from './types/identity';
|
||||||
import { base32Decode, base32Encode } from '../../shared/base32';
|
import * as card from './types/card';
|
||||||
|
import * as key from './types/key';
|
||||||
// Which types support add/edit in Slice 6.
|
import * as totp from './types/totp';
|
||||||
function isEditableType(t: ItemType): boolean {
|
|
||||||
return t === 'login';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
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 state = getState();
|
||||||
const existing = mode === 'edit' ? state.selectedItem : null;
|
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.
|
switch (type) {
|
||||||
const type: ItemType = existing?.type ?? 'login';
|
case 'login': return login.renderForm(app, mode, existing);
|
||||||
|
case 'secure_note': return secureNote.renderForm(app, mode, existing);
|
||||||
if (!isEditableType(type)) {
|
case 'identity': return identity.renderForm(app, mode, existing);
|
||||||
renderComingSoon(app, type);
|
case 'card': return card.renderForm(app, mode, existing);
|
||||||
return;
|
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 {
|
function renderComingSoon(app: HTMLElement, type: ItemType): void {
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
<div class="detail-title" style="margin-bottom:16px;">${escapeHtml(type.replace('_', ' '))}</div>
|
<div class="detail-title" style="margin-bottom:16px;">${type.replace('_', ' ')}</div>
|
||||||
<p class="muted">editing ${escapeHtml(type)} items is coming in a later slice.</p>
|
<p class="muted">Editing <strong>${type}</strong> items is not available yet.</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="form-actions">
|
|
||||||
<button class="btn" id="back-btn">back</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
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 });
|
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 () => {
|
document.getElementById('sync-btn')?.addEventListener('click', async () => {
|
||||||
setState({ loading: true, error: null });
|
setState({ loading: true, error: null });
|
||||||
@@ -90,7 +93,10 @@ export function renderItemList(app: HTMLElement): void {
|
|||||||
navigate('locked');
|
navigate('locked');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));
|
document.getElementById('settings-btn')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showSettingsPicker(e.currentTarget as HTMLElement);
|
||||||
|
});
|
||||||
|
|
||||||
// Item row clicks.
|
// Item row clicks.
|
||||||
const rows = app.querySelectorAll('.entry-row');
|
const rows = app.querySelectorAll('.entry-row');
|
||||||
@@ -215,3 +221,169 @@ function handleListKeydown(e: KeyboardEvent): void {
|
|||||||
return;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Settings picker popover (device vs vault)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [
|
||||||
|
{ view: 'settings', icon: '🖥', label: 'device settings' },
|
||||||
|
{ view: 'settings-vault', icon: '🔐', label: 'vault settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function showSettingsPicker(anchor: HTMLElement): void {
|
||||||
|
document.querySelectorAll('.settings-picker').forEach((el) => el.remove());
|
||||||
|
|
||||||
|
const picker = document.createElement('div');
|
||||||
|
picker.className = 'settings-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: '170px',
|
||||||
|
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 SETTINGS_OPTIONS) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
Object.assign(row.style, {
|
||||||
|
padding: '6px 10px', cursor: 'pointer', color: '#c9d1d9',
|
||||||
|
borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
});
|
||||||
|
const iconSpan = document.createElement('span');
|
||||||
|
iconSpan.textContent = opt.icon;
|
||||||
|
Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', textAlign: 'center' });
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.textContent = opt.label;
|
||||||
|
row.appendChild(iconSpan);
|
||||||
|
row.appendChild(labelSpan);
|
||||||
|
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);
|
||||||
|
navigate(opt.view);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
236
extension/src/popup/components/settings-vault.ts
Normal file
236
extension/src/popup/components/settings-vault.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/// Vault-level settings screen. Covers retention (trash + field history),
|
||||||
|
/// generator defaults (preview + "configure" → opens popover), and
|
||||||
|
/// autofill origin-ack revocation.
|
||||||
|
|
||||||
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||||
|
import type {
|
||||||
|
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
|
||||||
|
} from '../../shared/types';
|
||||||
|
import { openGeneratorPopover } from './generator-popover';
|
||||||
|
|
||||||
|
let pendingSettings: VaultSettings | null = null;
|
||||||
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
if (activeKeyHandler) {
|
||||||
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
|
activeKeyHandler = null;
|
||||||
|
}
|
||||||
|
pendingSettings = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Retention helpers ---
|
||||||
|
|
||||||
|
function trashRetentionToValue(r: TrashRetention): string {
|
||||||
|
if (r.kind === 'forever') return 'forever';
|
||||||
|
return `days:${r.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToTrashRetention(v: string): TrashRetention {
|
||||||
|
if (v === 'forever') return { kind: 'forever' };
|
||||||
|
const m = /^days:(\d+)$/.exec(v);
|
||||||
|
if (m) return { kind: 'days', value: Number(m[1]) };
|
||||||
|
return { kind: 'forever' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyRetentionToValue(r: HistoryRetention): string {
|
||||||
|
if (r.kind === 'forever') return 'forever';
|
||||||
|
if (r.kind === 'last_n') return `last_n:${r.value}`;
|
||||||
|
return `days:${r.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToHistoryRetention(v: string): HistoryRetention {
|
||||||
|
if (v === 'forever') return { kind: 'forever' };
|
||||||
|
const mLast = /^last_n:(\d+)$/.exec(v);
|
||||||
|
if (mLast) return { kind: 'last_n', value: Number(mLast[1]) };
|
||||||
|
const mDays = /^days:(\d+)$/.exec(v);
|
||||||
|
if (mDays) return { kind: 'days', value: Number(mDays[1]) };
|
||||||
|
return { kind: 'forever' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generator summary ---
|
||||||
|
|
||||||
|
function generatorSummary(req: GeneratorRequest): string {
|
||||||
|
if (req.kind === 'random') {
|
||||||
|
const classes: string[] = [];
|
||||||
|
if (req.classes.lower) classes.push('lower');
|
||||||
|
if (req.classes.upper) classes.push('upper');
|
||||||
|
if (req.classes.digits) classes.push('digits');
|
||||||
|
if (req.classes.symbols) classes.push('symbols');
|
||||||
|
const sc = req.symbol_charset.kind;
|
||||||
|
return `Random, ${req.length} chars, ${classes.join('+') || 'no classes'}, ${sc} symbols`;
|
||||||
|
}
|
||||||
|
return `BIP39, ${req.word_count} words, "${req.separator}" separator, ${req.capitalization}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Time formatting ---
|
||||||
|
|
||||||
|
function relativeTime(unixSec: number): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = now - unixSec;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
|
||||||
|
export function renderVaultSettings(app: HTMLElement): void {
|
||||||
|
const state = getState();
|
||||||
|
const base = state.vaultSettings;
|
||||||
|
if (!base) {
|
||||||
|
app.innerHTML = `<div class="pad"><p class="muted">Vault settings not loaded yet.</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings;
|
||||||
|
|
||||||
|
function rerender(): void {
|
||||||
|
if (!pendingSettings) return;
|
||||||
|
const acksEntries = Object.entries(pendingSettings.autofill_origin_acks)
|
||||||
|
.sort(([, a], [, b]) => b - a);
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad">
|
||||||
|
<div class="settings-header">
|
||||||
|
<button class="btn" id="back-btn">← back</button>
|
||||||
|
<h3 style="margin:0;">vault settings</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">retention</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">trash</span>
|
||||||
|
<select id="trash-retention">
|
||||||
|
<option value="forever">Forever</option>
|
||||||
|
<option value="days:7">7 days</option>
|
||||||
|
<option value="days:30">30 days</option>
|
||||||
|
<option value="days:60">60 days</option>
|
||||||
|
<option value="days:90">90 days</option>
|
||||||
|
<option value="days:180">180 days</option>
|
||||||
|
<option value="days:365">365 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">field history</span>
|
||||||
|
<select id="history-retention">
|
||||||
|
<option value="forever">Forever</option>
|
||||||
|
<option value="last_n:3">Last 3</option>
|
||||||
|
<option value="last_n:5">Last 5</option>
|
||||||
|
<option value="last_n:10">Last 10</option>
|
||||||
|
<option value="days:30">30 days</option>
|
||||||
|
<option value="days:90">90 days</option>
|
||||||
|
<option value="days:365">365 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">generator</div>
|
||||||
|
<p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p>
|
||||||
|
<button class="btn" id="configure-gen">configure ▾</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">autofill origins</div>
|
||||||
|
${acksEntries.length === 0
|
||||||
|
? `<p class="muted">No origins acknowledged yet.</p>`
|
||||||
|
: acksEntries.map(([host, ts]) => `
|
||||||
|
<div class="ack-row">
|
||||||
|
<span class="ack-row__host">${escapeHtml(host)}</span>
|
||||||
|
<span class="ack-row__meta">${escapeHtml(relativeTime(ts))}</span>
|
||||||
|
<button class="ack-row__revoke" data-revoke="${escapeHtml(host)}">revoke</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button class="btn" id="discard-btn">discard</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Set current select values
|
||||||
|
(document.getElementById('trash-retention') as HTMLSelectElement).value =
|
||||||
|
trashRetentionToValue(pendingSettings.trash_retention);
|
||||||
|
(document.getElementById('history-retention') as HTMLSelectElement).value =
|
||||||
|
historyRetentionToValue(pendingSettings.field_history_retention);
|
||||||
|
|
||||||
|
wireHandlers();
|
||||||
|
updateSaveEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSaveEnabled(): void {
|
||||||
|
const saveBtn = document.getElementById('save-btn') as HTMLButtonElement | null;
|
||||||
|
if (!saveBtn || !pendingSettings || !base) return;
|
||||||
|
const changed = JSON.stringify(pendingSettings) !== JSON.stringify(base);
|
||||||
|
saveBtn.disabled = !changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireHandlers(): void {
|
||||||
|
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
|
document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
|
|
||||||
|
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
||||||
|
if (!pendingSettings) return;
|
||||||
|
pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
|
||||||
|
updateSaveEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('history-retention')?.addEventListener('change', (e) => {
|
||||||
|
if (!pendingSettings) return;
|
||||||
|
pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
|
||||||
|
updateSaveEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (!pendingSettings) return;
|
||||||
|
const host = btn.dataset.revoke ?? '';
|
||||||
|
delete pendingSettings.autofill_origin_acks[host];
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('configure-gen')?.addEventListener('click', (e) => {
|
||||||
|
if (!pendingSettings) return;
|
||||||
|
const anchor = e.currentTarget as HTMLElement;
|
||||||
|
openGeneratorPopover({
|
||||||
|
anchor,
|
||||||
|
initial: pendingSettings.generator_defaults,
|
||||||
|
onPicked: () => {/* no-op — user is here to save as default, not pick */},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
|
if (!pendingSettings) return;
|
||||||
|
const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings });
|
||||||
|
if (resp.ok) {
|
||||||
|
// Refresh cached state and navigate back.
|
||||||
|
const refreshed = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (refreshed.ok && refreshed.data) {
|
||||||
|
const vs = (refreshed.data as { settings: VaultSettings }).settings;
|
||||||
|
if (vs) {
|
||||||
|
setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navigate('list');
|
||||||
|
} else {
|
||||||
|
setState({ error: resp.error });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (activeKeyHandler) document.removeEventListener('keydown', activeKeyHandler);
|
||||||
|
activeKeyHandler = null;
|
||||||
|
navigate('list');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
activeKeyHandler = handler;
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
}
|
||||||
@@ -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,58 @@
|
|||||||
|
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: 'login',
|
||||||
|
vaultSettings: null, generatorDefaults: null,
|
||||||
|
}));
|
||||||
|
const escapeHtml = (s: string) => s
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { renderForm } from '../login';
|
||||||
|
import { sendMessage } from '../../../popup';
|
||||||
|
|
||||||
|
describe('Login form packs sectionsDraft into Item.sections', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
vi.mocked(sendMessage).mockReset();
|
||||||
|
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists added sections and fields', async () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderForm(app, 'add', null);
|
||||||
|
|
||||||
|
(document.getElementById('f-title') as HTMLInputElement).value = 'Example';
|
||||||
|
|
||||||
|
(document.querySelector('.disclosure__toggle') as HTMLButtonElement).click();
|
||||||
|
(document.querySelector('.add-section') as HTMLButtonElement).click();
|
||||||
|
(document.querySelector('[data-add-field="text"]') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
const labelInput = document.querySelector('[data-field-label]') as HTMLInputElement;
|
||||||
|
labelInput.value = 'recovery email';
|
||||||
|
labelInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
const valueInput = document.querySelector('[data-field-value-input]') as HTMLInputElement;
|
||||||
|
valueInput.value = 'backup@example.com';
|
||||||
|
valueInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
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.sections).toHaveLength(1);
|
||||||
|
expect(msg.item.sections[0].fields).toHaveLength(1);
|
||||||
|
expect(msg.item.sections[0].fields[0].label).toBe('recovery email');
|
||||||
|
expect(msg.item.sections[0].fields[0].value).toEqual({ kind: 'text', value: 'backup@example.com' });
|
||||||
|
expect(msg.item.sections[0].fields[0].id).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
280
extension/src/popup/components/types/card.ts
Normal file
280
extension/src/popup/components/types/card.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/// 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, Section } from '../../../shared/types';
|
||||||
|
import {
|
||||||
|
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
|
} 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;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
if (activeKeyHandler) {
|
||||||
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
|
activeKeyHandler = null;
|
||||||
|
}
|
||||||
|
if (activeFormEscHandler) {
|
||||||
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
|
activeFormEscHandler = null;
|
||||||
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) : ''}
|
||||||
|
${renderSections(item, 'card')}
|
||||||
|
<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 sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rerender = (): void => {
|
||||||
|
const disclosure = app.querySelector('.disclosure');
|
||||||
|
if (!disclosure) return;
|
||||||
|
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
};
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
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, sectionsDraft);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, sectionsDraft: Section[]): 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: sectionsDraft,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
222
extension/src/popup/components/types/identity.ts
Normal file
222
extension/src/popup/components/types/identity.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/// 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, Section } from '../../../shared/types';
|
||||||
|
import {
|
||||||
|
renderRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
|
} from '../fields';
|
||||||
|
|
||||||
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
if (activeKeyHandler) {
|
||||||
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
|
activeKeyHandler = null;
|
||||||
|
}
|
||||||
|
if (activeFormEscHandler) {
|
||||||
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
|
activeFormEscHandler = null;
|
||||||
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }) : ''}
|
||||||
|
${renderSections(item, 'identity')}
|
||||||
|
<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;
|
||||||
|
|
||||||
|
const sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rerender = (): void => {
|
||||||
|
const disclosure = app.querySelector('.disclosure');
|
||||||
|
if (!disclosure) return;
|
||||||
|
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
};
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
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, sectionsDraft);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, sectionsDraft: Section[]): 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: sectionsDraft,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
223
extension/src/popup/components/types/key.ts
Normal file
223
extension/src/popup/components/types/key.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/// 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, Section } from '../../../shared/types';
|
||||||
|
import {
|
||||||
|
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
|
} from '../fields';
|
||||||
|
|
||||||
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
if (activeKeyHandler) {
|
||||||
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
|
activeKeyHandler = null;
|
||||||
|
}
|
||||||
|
if (activeFormEscHandler) {
|
||||||
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
|
activeFormEscHandler = null;
|
||||||
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) : ''}
|
||||||
|
${renderSections(item, 'key')}
|
||||||
|
<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;
|
||||||
|
|
||||||
|
const sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rerender = (): void => {
|
||||||
|
const disclosure = app.querySelector('.disclosure');
|
||||||
|
if (!disclosure) return;
|
||||||
|
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
};
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
// 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, sectionsDraft);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, sectionsDraft: Section[]): 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: sectionsDraft,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
385
extension/src/popup/components/types/login.ts
Normal file
385
extension/src/popup/components/types/login.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
/// 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, Section, TotpConfig } from '../../../shared/types';
|
||||||
|
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
|
||||||
|
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||||
|
import {
|
||||||
|
renderRow,
|
||||||
|
renderConcealedRow,
|
||||||
|
renderSignatureBlock,
|
||||||
|
wireFieldHandlers,
|
||||||
|
renderSections,
|
||||||
|
renderSectionsEditor,
|
||||||
|
wireSectionsEditor,
|
||||||
|
} from '../fields';
|
||||||
|
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover';
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
|
closeGeneratorPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 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 }) : ''}
|
||||||
|
${renderSections(item, 'login')}
|
||||||
|
<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;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
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 ?? '';
|
||||||
|
|
||||||
|
const sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rerender = (): void => {
|
||||||
|
const disclosure = app.querySelector('.disclosure');
|
||||||
|
if (!disclosure) return;
|
||||||
|
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
};
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
|
||||||
|
const anchor = e.currentTarget as HTMLElement;
|
||||||
|
const initial = getState().generatorDefaults ?? DEFAULT_PASSWORD_REQUEST;
|
||||||
|
openGeneratorPopover({
|
||||||
|
anchor,
|
||||||
|
initial,
|
||||||
|
onPicked: (value) => {
|
||||||
|
const pw = document.getElementById('f-password') as HTMLInputElement | null;
|
||||||
|
if (pw) { pw.value = value; pw.type = 'text'; }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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, sectionsDraft);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, sectionsDraft: Section[]): 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: sectionsDraft,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
184
extension/src/popup/components/types/secure-note.ts
Normal file
184
extension/src/popup/components/types/secure-note.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/// 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, Section } from '../../../shared/types';
|
||||||
|
import {
|
||||||
|
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
|
} from '../fields';
|
||||||
|
|
||||||
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
if (activeKeyHandler) {
|
||||||
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
|
activeKeyHandler = null;
|
||||||
|
}
|
||||||
|
if (activeFormEscHandler) {
|
||||||
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
|
activeFormEscHandler = null;
|
||||||
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })}
|
||||||
|
${renderSections(item, 'secure-note')}
|
||||||
|
<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 ?? '' : '';
|
||||||
|
|
||||||
|
const sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rerender = (): void => {
|
||||||
|
const disclosure = app.querySelector('.disclosure');
|
||||||
|
if (!disclosure) return;
|
||||||
|
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
};
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
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, sectionsDraft);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, sectionsDraft: Section[]): 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: sectionsDraft,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
365
extension/src/popup/components/types/totp.ts
Normal file
365
extension/src/popup/components/types/totp.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
/// 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, Section, TotpKind } from '../../../shared/types';
|
||||||
|
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||||
|
import {
|
||||||
|
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
|
} 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;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 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 })}
|
||||||
|
${renderSections(item, 'totp')}
|
||||||
|
<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 sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
<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;
|
||||||
|
// Preserve the disclosure's live expanded state across kind-toggle re-render.
|
||||||
|
const currentDisclosure = app.querySelector('.disclosure');
|
||||||
|
if (currentDisclosure) {
|
||||||
|
sectionsExpanded = currentDisclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
}
|
||||||
|
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, sectionsDraft);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rerender only the sections editor in place (used by structural section
|
||||||
|
// mutations — add/remove). Reuses the form-wide reRender for simplicity
|
||||||
|
// since kind toggle already re-mounts the full inner DOM; here we just
|
||||||
|
// need to preserve sectionsExpanded and swap the disclosure block.
|
||||||
|
const sectionsRerender = (): void => {
|
||||||
|
const disclosure = app.querySelector('.disclosure');
|
||||||
|
if (!disclosure) return;
|
||||||
|
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
||||||
|
};
|
||||||
|
|
||||||
|
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, sectionsDraft);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
||||||
|
|
||||||
|
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, sectionsDraft: Section[]): 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, sectionsDraft);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTotp(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): 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: sectionsDraft,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/// Popup entry point — state machine with view routing.
|
/// Popup entry point — state machine with view routing.
|
||||||
///
|
///
|
||||||
/// Views: setup | locked | list | detail | add | edit
|
/// Views: setup | locked | list | detail | add | edit | settings | settings-vault
|
||||||
/// Navigation works by updating `currentState` and calling `render()`.
|
/// Navigation works by updating `currentState` and calling `render()`.
|
||||||
|
|
||||||
import type { Request, Response } from '../shared/messages';
|
import type { Request, Response } from '../shared/messages';
|
||||||
@@ -10,17 +10,21 @@ import { renderItemList } from './components/item-list';
|
|||||||
import { renderItemDetail } from './components/item-detail';
|
import { renderItemDetail } from './components/item-detail';
|
||||||
import { renderItemForm } from './components/item-form';
|
import { renderItemForm } from './components/item-form';
|
||||||
import { renderSettings } from './components/settings';
|
import { renderSettings } from './components/settings';
|
||||||
|
import { renderVaultSettings } from './components/settings-vault';
|
||||||
|
|
||||||
// --- Escape HTML to prevent XSS ---
|
// --- Escape HTML to prevent XSS ---
|
||||||
export function escapeHtml(str: string): string {
|
export function escapeHtml(str: string): string {
|
||||||
const div = document.createElement('div');
|
return str
|
||||||
div.textContent = str;
|
.replace(/&/g, '&')
|
||||||
return div.innerHTML;
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
|
||||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
|
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault';
|
||||||
|
|
||||||
export interface PopupState {
|
export interface PopupState {
|
||||||
view: View;
|
view: View;
|
||||||
@@ -38,6 +42,9 @@ export interface PopupState {
|
|||||||
// to the content script. See router/popup-only.ts#handleFillCredentials.
|
// to the content script. See router/popup-only.ts#handleFillCredentials.
|
||||||
capturedTabId: number | null;
|
capturedTabId: number | null;
|
||||||
capturedUrl: string;
|
capturedUrl: string;
|
||||||
|
newType: import('../shared/types').ItemType | null;
|
||||||
|
vaultSettings: import('../shared/types').VaultSettings | null;
|
||||||
|
generatorDefaults: import('../shared/types').GeneratorRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentState: PopupState = {
|
let currentState: PopupState = {
|
||||||
@@ -52,6 +59,9 @@ let currentState: PopupState = {
|
|||||||
loading: false,
|
loading: false,
|
||||||
capturedTabId: null,
|
capturedTabId: null,
|
||||||
capturedUrl: '',
|
capturedUrl: '',
|
||||||
|
newType: null,
|
||||||
|
vaultSettings: null,
|
||||||
|
generatorDefaults: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getState(): PopupState {
|
export function getState(): PopupState {
|
||||||
@@ -137,6 +147,9 @@ function render(): void {
|
|||||||
case 'settings':
|
case 'settings':
|
||||||
renderSettings(app);
|
renderSettings(app);
|
||||||
break;
|
break;
|
||||||
|
case 'settings-vault':
|
||||||
|
renderVaultSettings(app);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +183,16 @@ async function init(): Promise<void> {
|
|||||||
const listResp = await sendMessage({ type: 'list_items' });
|
const listResp = await sendMessage({ type: 'list_items' });
|
||||||
if (listResp.ok) {
|
if (listResp.ok) {
|
||||||
const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||||
|
// Fetch vault settings so subsequent screens (generator popover,
|
||||||
|
// settings-vault) can show current values without a round-trip.
|
||||||
|
// Failures swallow silently — list view still renders; consumers
|
||||||
|
// can show "settings not loaded" if needed.
|
||||||
|
const vsResp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (vsResp.ok) {
|
||||||
|
const vs = (vsResp.data as { settings: import('../shared/types').VaultSettings }).settings;
|
||||||
|
currentState.vaultSettings = vs;
|
||||||
|
currentState.generatorDefaults = vs.generator_defaults;
|
||||||
|
}
|
||||||
navigate('list', { entries: listData.items });
|
navigate('list', { entries: listData.items });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,3 +459,235 @@ textarea {
|
|||||||
border-color: #3fb950;
|
border-color: #3fb950;
|
||||||
border-style: solid;
|
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; }
|
||||||
|
|
||||||
|
/* --- custom-section rendering (β₂ slice 1) --- */
|
||||||
|
.section-header {
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.section-separator {
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- custom-section editor (β₂ slice 2) --- */
|
||||||
|
.disclosure {
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.disclosure__toggle {
|
||||||
|
background: transparent; border: 0; color: #58a6ff;
|
||||||
|
cursor: pointer; font-size: 12px; padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.disclosure[data-expanded="false"] .disclosure__body { display: none; }
|
||||||
|
|
||||||
|
.section-editor__head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
margin-top: 10px; margin-bottom: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.section-editor__head .name { color: #c9d1d9; font-weight: 600; }
|
||||||
|
.section-editor__head .name.anon { color: #8b949e; font-style: italic; font-weight: normal; }
|
||||||
|
.section-editor__head .actions { color: #8b949e; font-size: 10px; margin-left: auto; }
|
||||||
|
.section-editor__head .actions button {
|
||||||
|
background: transparent; border: 0; color: inherit;
|
||||||
|
cursor: pointer; padding: 0; margin-left: 8px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.section-editor__head .actions button:hover { color: #c9d1d9; }
|
||||||
|
|
||||||
|
.section-editor__field {
|
||||||
|
display: grid; grid-template-columns: 120px 1fr auto;
|
||||||
|
gap: 4px; margin-bottom: 4px; font-size: 11px;
|
||||||
|
}
|
||||||
|
.section-editor__field input {
|
||||||
|
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||||||
|
padding: 3px 6px; border-radius: 3px; font: inherit; font-size: 11px;
|
||||||
|
}
|
||||||
|
.section-editor__field .delete-field {
|
||||||
|
background: transparent; border: 0; color: #f85149;
|
||||||
|
cursor: pointer; font-size: 14px; padding: 0 4px;
|
||||||
|
}
|
||||||
|
.section-editor__preserved {
|
||||||
|
font-size: 10px; color: #6e7681; font-style: italic;
|
||||||
|
padding: 4px 0 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-editor__add {
|
||||||
|
display: flex; gap: 6px; margin-top: 6px;
|
||||||
|
}
|
||||||
|
.section-editor__add button {
|
||||||
|
background: transparent; border: 1px solid #30363d; color: #8b949e;
|
||||||
|
padding: 2px 10px; border-radius: 3px; cursor: pointer;
|
||||||
|
font-size: 10px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.section-editor__add button:hover { color: #c9d1d9; border-color: #484f58; }
|
||||||
|
|
||||||
|
.disclosure__body .add-section {
|
||||||
|
margin-top: 12px; background: transparent;
|
||||||
|
border: 1px dashed #30363d; color: #8b949e;
|
||||||
|
padding: 6px 10px; border-radius: 4px; cursor: pointer;
|
||||||
|
width: 100%; font-size: 11px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; }
|
||||||
|
|
||||||
|
/* --- generator popover (β₂ slice 4) --- */
|
||||||
|
.generator-popover {
|
||||||
|
position: absolute; z-index: 9999999;
|
||||||
|
background: #161b22; border: 1px solid #30363d; border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||||
|
padding: 14px; min-width: 300px; max-width: 340px;
|
||||||
|
font-size: 11px; font-family: system-ui, sans-serif; color: #c9d1d9;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-title { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: lowercase; letter-spacing: 0.08em; }
|
||||||
|
.generator-popover .gen-close {
|
||||||
|
background: transparent; border: 0; color: #8b949e; cursor: pointer;
|
||||||
|
font-size: 14px; padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-row {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin: 6px 0;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-row__label {
|
||||||
|
color: #8b949e; width: 70px; flex-shrink: 0;
|
||||||
|
font-size: 10px; text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-toggle-group {
|
||||||
|
display: flex; gap: 0; border: 1px solid #30363d; border-radius: 3px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-toggle-group button {
|
||||||
|
background: transparent; border: 0; color: #8b949e;
|
||||||
|
padding: 3px 10px; cursor: pointer; font: inherit; font-size: 10px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-toggle-group button.active { background: #1f6feb; color: #fff; }
|
||||||
|
.generator-popover .gen-slider { flex: 1; }
|
||||||
|
.generator-popover .gen-slider + span {
|
||||||
|
color: #c9d1d9; font-variant-numeric: tabular-nums;
|
||||||
|
font-family: monospace; min-width: 24px; text-align: right;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-check-grid {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px 16px; margin: 6px 0; font-size: 11px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-check-grid label {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-preview {
|
||||||
|
margin: 10px 0 8px; padding: 8px 10px;
|
||||||
|
background: #0d1117; border: 1px solid #30363d; border-radius: 4px;
|
||||||
|
font-family: "SF Mono", "JetBrains Mono", monospace; color: #c9d1d9;
|
||||||
|
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-preview__regen {
|
||||||
|
flex-shrink: 0; background: transparent; border: 0;
|
||||||
|
color: #58a6ff; cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-actions {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px; margin-top: 10px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-actions .btn { font-size: 11px; padding: 5px 10px; }
|
||||||
|
|
||||||
|
/* --- settings-vault screen (β₂ slice 5) --- */
|
||||||
|
.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 14px; padding-top: 10px;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
.settings-section__title {
|
||||||
|
color: #8b949e; font-size: 10px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.settings-row {
|
||||||
|
display: grid; grid-template-columns: 110px 1fr;
|
||||||
|
gap: 6px 10px; align-items: center;
|
||||||
|
margin: 4px 0; font-size: 12px;
|
||||||
|
}
|
||||||
|
.settings-row__label { color: #8b949e; }
|
||||||
|
.settings-row select {
|
||||||
|
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||||||
|
padding: 3px 8px; border-radius: 3px; font: inherit; font-size: 11px;
|
||||||
|
}
|
||||||
|
.gen-preview-line {
|
||||||
|
margin: 0 0 6px; font-size: 11px; color: #c9d1d9;
|
||||||
|
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
.ack-row {
|
||||||
|
display: grid; grid-template-columns: 1fr auto auto;
|
||||||
|
gap: 8px; align-items: center;
|
||||||
|
padding: 4px 0; font-size: 11px;
|
||||||
|
border-bottom: 1px solid #161b22;
|
||||||
|
}
|
||||||
|
.ack-row__host { color: #c9d1d9; font-family: monospace; }
|
||||||
|
.ack-row__meta { color: #6e7681; font-size: 10px; }
|
||||||
|
.ack-row__revoke {
|
||||||
|
background: transparent; border: 0; color: #f85149;
|
||||||
|
cursor: pointer; font-size: 10px;
|
||||||
|
}
|
||||||
|
.settings-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 6px;
|
||||||
|
margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
|||||||
@@ -522,3 +522,213 @@ describe('capture_save_login', () => {
|
|||||||
expect(vault.encryptAndWriteItem).not.toHaveBeenCalled();
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- get_vault_settings / update_vault_settings (β₂ Slice 3) ---
|
||||||
|
|
||||||
|
describe('get_vault_settings / update_vault_settings', () => {
|
||||||
|
function primeUnlocked(state: RouterState): void {
|
||||||
|
vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never);
|
||||||
|
state.gitHost = {} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(session.getCurrent).mockReset();
|
||||||
|
vi.mocked(vault.fetchAndDecryptSettings).mockReset();
|
||||||
|
vi.mocked(vault.encryptAndWriteSettings).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get_vault_settings accepted from popup; returns VaultSettings', async () => {
|
||||||
|
const state = makeState();
|
||||||
|
primeUnlocked(state);
|
||||||
|
const mockSettings = {
|
||||||
|
trash_retention: { kind: 'days', value: 30 },
|
||||||
|
field_history_retention: { kind: 'forever' },
|
||||||
|
generator_defaults: {
|
||||||
|
kind: 'random', length: 20,
|
||||||
|
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
symbol_charset: { kind: 'safe_only' },
|
||||||
|
},
|
||||||
|
attachment_caps: {},
|
||||||
|
autofill_origin_acks: { 'github.com': 1000 },
|
||||||
|
};
|
||||||
|
vi.mocked(vault.fetchAndDecryptSettings).mockResolvedValueOnce(mockSettings as never);
|
||||||
|
const res = await route({ type: 'get_vault_settings' }, state, makePopupSender());
|
||||||
|
expect(res).toMatchObject({ ok: true });
|
||||||
|
if (res.ok) {
|
||||||
|
const d = res.data as { settings: typeof mockSettings };
|
||||||
|
expect(d.settings).toEqual(mockSettings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get_vault_settings rejected from content', async () => {
|
||||||
|
const state = makeState();
|
||||||
|
const res = await route({ type: 'get_vault_settings' }, state, makeContentSender());
|
||||||
|
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update_vault_settings accepted from popup; calls encryptAndWriteSettings', async () => {
|
||||||
|
const state = makeState();
|
||||||
|
primeUnlocked(state);
|
||||||
|
vi.mocked(vault.encryptAndWriteSettings).mockResolvedValueOnce(undefined);
|
||||||
|
const newSettings = {
|
||||||
|
trash_retention: { kind: 'forever' },
|
||||||
|
field_history_retention: { kind: 'last_n', value: 5 },
|
||||||
|
generator_defaults: {
|
||||||
|
kind: 'bip39', word_count: 6, separator: '-', capitalization: 'lower',
|
||||||
|
},
|
||||||
|
attachment_caps: {},
|
||||||
|
autofill_origin_acks: {},
|
||||||
|
};
|
||||||
|
const res = await route(
|
||||||
|
{ type: 'update_vault_settings', settings: newSettings as never },
|
||||||
|
state,
|
||||||
|
makePopupSender(),
|
||||||
|
);
|
||||||
|
expect(res).toMatchObject({ ok: true });
|
||||||
|
expect(vault.encryptAndWriteSettings).toHaveBeenCalledWith(
|
||||||
|
expect.anything(), expect.anything(), newSettings, expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update_vault_settings rejected from setup tab (not in SETUP_ALLOWED)', async () => {
|
||||||
|
const state = makeState();
|
||||||
|
const res = await route(
|
||||||
|
{ type: 'update_vault_settings', settings: {} as never },
|
||||||
|
state,
|
||||||
|
makeSetupSender(),
|
||||||
|
);
|
||||||
|
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/// via sender.url === popup.html (or setup.html for save_setup).
|
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||||
|
|
||||||
import type { PopupMessage, Response } from '../../shared/messages';
|
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 { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||||
import type { GitHost } from '../git-host';
|
import type { GitHost } from '../git-host';
|
||||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||||
@@ -107,11 +107,18 @@ export async function handle(
|
|||||||
const handle = session.getCurrent();
|
const handle = session.getCurrent();
|
||||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||||
if (item.core.type !== 'login' || !item.core.totp) {
|
// Resolve the TotpConfig from whichever carrier the item type uses.
|
||||||
return { ok: false, error: 'no_totp' };
|
// 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 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 } };
|
return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +149,11 @@ export async function handle(
|
|||||||
return { ok: true, data: { password } };
|
return { ok: true, data: { password } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'generate_passphrase': {
|
||||||
|
const passphrase = state.wasm.generate_passphrase(JSON.stringify(msg.request));
|
||||||
|
return { ok: true, data: { passphrase } };
|
||||||
|
}
|
||||||
|
|
||||||
case 'fill_credentials':
|
case 'fill_credentials':
|
||||||
return handleFillCredentials(msg, state);
|
return handleFillCredentials(msg, state);
|
||||||
|
|
||||||
@@ -164,6 +176,23 @@ export async function handle(
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'get_vault_settings': {
|
||||||
|
const handle = session.getCurrent();
|
||||||
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
|
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||||||
|
return { ok: true, data: { settings } };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update_vault_settings': {
|
||||||
|
const handle = session.getCurrent();
|
||||||
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
|
await vault.encryptAndWriteSettings(
|
||||||
|
state.gitHost, handle, msg.settings,
|
||||||
|
'settings: update vault-level config',
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
case 'get_blacklist':
|
case 'get_blacklist':
|
||||||
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
||||||
DeviceSettings, GeneratorRequest,
|
DeviceSettings, GeneratorRequest, VaultSettings,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Messages a popup (or setup page) may send ---
|
// --- Messages a popup (or setup page) may send ---
|
||||||
@@ -20,10 +20,13 @@ export type PopupMessage =
|
|||||||
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
|
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
|
||||||
| { type: 'rate_passphrase'; passphrase: string }
|
| { type: 'rate_passphrase'; passphrase: string }
|
||||||
| { type: 'generate_password'; request: GeneratorRequest }
|
| { type: 'generate_password'; request: GeneratorRequest }
|
||||||
|
| { type: 'generate_passphrase'; request: GeneratorRequest }
|
||||||
| { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
|
| { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
|
||||||
| { type: 'ack_autofill_origin'; hostname: string }
|
| { type: 'ack_autofill_origin'; hostname: string }
|
||||||
| { type: 'get_settings' }
|
| { type: 'get_settings' }
|
||||||
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
|
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
|
||||||
|
| { type: 'get_vault_settings' }
|
||||||
|
| { type: 'update_vault_settings'; settings: VaultSettings }
|
||||||
| { type: 'get_blacklist' }
|
| { type: 'get_blacklist' }
|
||||||
| { type: 'remove_blacklist'; hostname: string };
|
| { type: 'remove_blacklist'; hostname: string };
|
||||||
|
|
||||||
@@ -88,13 +91,19 @@ export interface RatePassphraseResponse extends Extract<Response, { ok: true }>
|
|||||||
data: { score: number; guesses_log10: number };
|
data: { score: number; guesses_log10: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VaultSettingsResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { settings: VaultSettings };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Capability sets (consumed by the router) ---
|
// --- Capability sets (consumed by the router) ---
|
||||||
|
|
||||||
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||||
'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
|
'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
|
||||||
'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
|
'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
|
||||||
'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials',
|
'save_setup', 'rate_passphrase', 'generate_password', 'generate_passphrase',
|
||||||
'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist',
|
'fill_credentials',
|
||||||
|
'ack_autofill_origin', 'get_settings', 'update_settings',
|
||||||
|
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
||||||
'remove_blacklist',
|
'remove_blacklist',
|
||||||
] as PopupMessage['type'][]);
|
] as PopupMessage['type'][]);
|
||||||
|
|
||||||
|
|||||||
@@ -183,15 +183,24 @@ export interface ManifestEntry {
|
|||||||
attachment_summaries: AttachmentSummary[];
|
attachment_summaries: AttachmentSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Vault settings (only the fields α touches) ---
|
// --- Vault settings ---
|
||||||
// Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md
|
// Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md
|
||||||
// We leave retention/generator/caps opaque to α so we don't accidentally mutate them.
|
// β₂ tightens retention + generator_defaults; γ owns attachment_caps.
|
||||||
|
|
||||||
|
export type TrashRetention =
|
||||||
|
| { kind: 'forever' }
|
||||||
|
| { kind: 'days'; value: number };
|
||||||
|
|
||||||
|
export type HistoryRetention =
|
||||||
|
| { kind: 'forever' }
|
||||||
|
| { kind: 'last_n'; value: number }
|
||||||
|
| { kind: 'days'; value: number };
|
||||||
|
|
||||||
export interface VaultSettings {
|
export interface VaultSettings {
|
||||||
trash_retention: unknown;
|
trash_retention: TrashRetention;
|
||||||
field_history_retention: unknown;
|
field_history_retention: HistoryRetention;
|
||||||
generator_defaults: unknown;
|
generator_defaults: GeneratorRequest;
|
||||||
attachment_caps: unknown;
|
attachment_caps: unknown; // opaque — γ tightens
|
||||||
autofill_origin_acks: Record<string, number>;
|
autofill_origin_acks: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user