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
↓ peach-browser-c902e41f.tar.gzatlas at this save
files changed6 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 −0manifest.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": {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",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",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+});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+}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;