c902e41fa3da

feat: add adoption consent modal (v4.0.16)

time
2026-06-19 19:54 UTC · 4 months ago
machine
koh2
agent
gpt-5
session
vault adoption integration
parent
d3b26d4046c9
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/components/AdoptionConsentModal.test.tsx+85 −0src/popup/components/AdoptionConsentModal.tsx+99 −0src/popup/styles/popup.css+80 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "4.0.15",5+  "version": "4.0.16",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.15",4+  "version": "4.0.16",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.0.15",3+  "version": "4.0.16",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/components/AdoptionConsentModal.test.tsx+85 −0
@@ -0,0 +1,85 @@1+// @vitest-environment jsdom2+3+import { afterEach, describe, expect, it, vi } from "vitest";4+import { AdoptionConsentModal } from "./AdoptionConsentModal";5+import { flushView, inputText, renderView } from "../views/view-test-utils";6+7+function button(container: HTMLElement, text: string): HTMLButtonElement {8+  const found = Array.from(container.querySelectorAll("button")).find((b) =>9+    (b.textContent ?? "").includes(text),10+  );11+  if (!found) throw new Error(`button not found: ${text}`);12+  return found as HTMLButtonElement;13+}14+15+function mount(overrides: Partial<Parameters<typeof AdoptionConsentModal>[0]> = {}) {16+  const props = {17+    receiverDeviceName: "June's iPhone",18+    sas: "895245",19+    verifyMasterPassword: vi.fn(async () => true),20+    onCancel: vi.fn(),21+    onApprove: vi.fn(async () => {}),22+    ...overrides,23+  };24+  const rendered = renderView(<AdoptionConsentModal {...props} />);25+  return { ...rendered, props };26+}27+28+describe("AdoptionConsentModal", () => {29+  const cleanups: Array<() => void> = [];30+31+  afterEach(() => {32+    while (cleanups.length) cleanups.pop()!();33+  });34+35+  function mounted(overrides: Partial<Parameters<typeof AdoptionConsentModal>[0]> = {}) {36+    const rendered = mount(overrides);37+    cleanups.push(rendered.dispose);38+    return rendered;39+  }40+41+  it("T-A16: renders receiver device name, consequence copy, and SAS code", () => {42+    const { container } = mounted();43+    expect(container.textContent).toContain("June's iPhone");44+    expect(container.textContent).toContain("copies your entire vault including all passwords and Pro");45+    expect(container.textContent).toContain("8 9 5 2 4 5");46+  });47+48+  it("T-A17: Share vault button is disabled until a password is entered", () => {49+    const { container } = mounted();50+    const share = button(container, "Share vault");51+    expect(share.disabled).toBe(true);52+    inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "pw");53+    expect(share.disabled).toBe(false);54+  });55+56+  it("T-A18: wrong password shows inline error, clears the field, and does not approve", async () => {57+    const { container, props } = mounted({ verifyMasterPassword: vi.fn(async () => false) });58+    const input = container.querySelector("#adoption-consent-password") as HTMLInputElement;59+    inputText(input, "wrong");60+    button(container, "Share vault").click();61+    await flushView();62+63+    expect(container.textContent).toContain("Incorrect password");64+    expect(input.value).toBe("");65+    expect(document.activeElement).toBe(input);66+    expect(props.onApprove).not.toHaveBeenCalled();67+  });68+69+  it("T-A19: correct password calls onApprove", async () => {70+    const { container, props } = mounted({ verifyMasterPassword: vi.fn(async () => true) });71+    inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "correct");72+    button(container, "Share vault").click();73+    await flushView();74+75+    expect(props.verifyMasterPassword).toHaveBeenCalledWith("correct");76+    expect(props.onApprove).toHaveBeenCalledTimes(1);77+  });78+79+  it("T-A20: cancel closes without approval", () => {80+    const { container, props } = mounted();81+    button(container, "Cancel").click();82+    expect(props.onCancel).toHaveBeenCalledTimes(1);83+    expect(props.onApprove).not.toHaveBeenCalled();84+  });85+});

view file

src/popup/components/AdoptionConsentModal.tsx+99 −0
@@ -0,0 +1,99 @@1+import { Show, createSignal } from "solid-js";2+3+export interface AdoptionConsentModalProps {4+  receiverDeviceName: string;5+  sas: string;6+  verifyMasterPassword(password: string): Promise<boolean>;7+  onCancel(): void;8+  onApprove(): Promise<void>;9+}10+11+export function AdoptionConsentModal(props: AdoptionConsentModalProps) {12+  const [password, setPassword] = createSignal("");13+  const [error, setError] = createSignal("");14+  const [busy, setBusy] = createSignal(false);15+16+  async function submit() {17+    if (!password() || busy()) return;18+    setBusy(true);19+    setError("");20+    try {21+      const ok = await props.verifyMasterPassword(password());22+      if (!ok) {23+        setPassword("");24+        setError("Incorrect password");25+        queueMicrotask(() => document.getElementById("adoption-consent-password")?.focus());26+        return;27+      }28+      await props.onApprove();29+    } finally {30+      setBusy(false);31+    }32+  }33+34+  return (35+    <div class="peach-adoption-consent-backdrop" role="presentation">36+      <section37+        class="peach-adoption-consent"38+        role="dialog"39+        aria-modal="true"40+        aria-labelledby="adoption-consent-title"41+      >42+        <div class="peach-adoption-consent-icon" aria-hidden="true">!</div>43+        <h2 id="adoption-consent-title">Share vault with new device?</h2>44+        <p class="peach-adoption-consent-device">45+          <strong>{props.receiverDeviceName}</strong> wants to join this vault.46+        </p>47+        <p class="peach-adoption-consent-warning">48+          This copies your entire vault including all passwords and Pro to that device.49+        </p>50+        <div class="peach-adoption-consent-sas">51+          <span>Confirm the code matches</span>52+          <strong>{props.sas.replace(/(\d)(?=\d)/g, "$1 ")}</strong>53+        </div>54+        <label class="peach-label" for="adoption-consent-password">55+          Enter your master password to approve56+        </label>57+        <input58+          id="adoption-consent-password"59+          class="peach-underline-input"60+          type="password"61+          autocomplete="current-password"62+          value={password()}63+          disabled={busy()}64+          aria-invalid={error() ? "true" : "false"}65+          aria-describedby={error() ? "adoption-consent-error" : undefined}66+          onInput={(event) => {67+            setPassword((event.target as HTMLInputElement).value);68+            if (error()) setError("");69+          }}70+          onKeyDown={(event) => {71+            if (event.key === "Enter") void submit();72+            if (event.key === "Escape") props.onCancel();73+          }}74+        />75+        <Show when={error()}>76+          <p id="adoption-consent-error" class="peach-hint peach-hint-error">{error()}</p>77+        </Show>78+        <div class="peach-adoption-consent-actions">79+          <button80+            type="button"81+            class="peach-btn peach-btn--ghost peach-btn--md"82+            disabled={busy()}83+            onClick={props.onCancel}84+          >85+            Cancel86+          </button>87+          <button88+            type="button"89+            class="peach-btn peach-btn--accent peach-btn--md peach-adoption-consent-share"90+            disabled={!password() || busy()}91+            onClick={() => void submit()}92+          >93+            {busy() ? "Sharing..." : "Share vault ->"}94+          </button>95+        </div>96+      </section>97+    </div>98+  );99+}

view file

src/popup/styles/popup.css+80 −0
@@ -7366,6 +7366,86 @@73667366   color: rgb(var(--danger-tint));73677367 }73687368 7369+.peach-adoption-consent-backdrop {7370+  position: fixed;7371+  inset: 0;7372+  z-index: 1000;7373+  display: grid;7374+  place-items: center;7375+  padding: 18px;7376+  background: rgba(15, 10, 5, 0.72);7377+}7378+7379+.peach-adoption-consent {7380+  width: min(100%, 340px);7381+  border: 1px solid color-mix(in srgb, #f59e0b 54%, var(--peach-border));7382+  border-radius: 8px;7383+  padding: 18px;7384+  background: color-mix(in srgb, #2c1b08 84%, var(--peach-surface));7385+  color: var(--peach-t0);7386+  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.42);7387+}7388+7389+.peach-adoption-consent-icon {7390+  width: 32px;7391+  height: 32px;7392+  display: grid;7393+  place-items: center;7394+  border-radius: 50%;7395+  background: #f59e0b;7396+  color: #1f1304;7397+  font-weight: 800;7398+}7399+7400+.peach-adoption-consent h2 {7401+  margin: 12px 0 8px;7402+  font-size: 1.22rem;7403+  line-height: 1.15;7404+}7405+7406+.peach-adoption-consent-device,7407+.peach-adoption-consent-warning {7408+  margin: 0 0 12px;7409+  color: var(--peach-t1);7410+  line-height: 1.35;7411+}7412+7413+.peach-adoption-consent-device strong {7414+  color: var(--peach-t0);7415+}7416+7417+.peach-adoption-consent-warning {7418+  color: #ffd79a;7419+}7420+7421+.peach-adoption-consent-sas {7422+  display: flex;7423+  justify-content: space-between;7424+  align-items: center;7425+  gap: 12px;7426+  margin: 14px 0;7427+  padding: 10px;7428+  border-radius: 6px;7429+  background: rgba(245, 158, 11, 0.12);7430+}7431+7432+.peach-adoption-consent-sas strong {7433+  font-variant-numeric: tabular-nums;7434+  letter-spacing: 0;7435+  white-space: nowrap;7436+}7437+7438+.peach-adoption-consent-actions {7439+  display: flex;7440+  justify-content: space-between;7441+  gap: 10px;7442+  margin-top: 16px;7443+}7444+7445+.peach-adoption-consent-actions > button {7446+  flex: 1;7447+}7448+73697449 /* ── Panic flow (three-step destruction) ───────────────────────────────── */73707450 .peach-panic {73717451   display: flex;

view file