800e374c624b

feat: UI-3 + UI-4 Sync settings — pending vouched-device Allow/Reject rows (CONFIRM/REJECT) with live badge, rotate-sync-identity danger button + confirm dialog (ROTATE_SYNC_IDENTITY) routing to new recovery words

time
2026-06-10 03:41 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
audit fix integration
parent
b5317629a444
1 file src/popup/views/Settings.tsx+143 −1
src/popup/views/Settings.tsx+143 −1
@@ -1,4 +1,4 @@1-import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js";1+import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";22 import { sendMessage } from "../App";33 import { PeachMark } from "../components/PeachMark";44 import { StackDeck } from "../components/settings/StackDeck";@@ -258,6 +258,62 @@258258   const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);259259   const [passkeySaving, setPasskeySaving] = createSignal(false);260260 261+  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting262+  // explicit confirmation before they become sync targets.263+  interface PendingVouched { deviceId: string; deviceName: string }264+  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);265+  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);266+  // UI-4 / MV-8: rotate sync identity confirmation dialog.267+  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);268+  const [rotating, setRotating] = createSignal(false);269+  const [rotateError, setRotateError] = createSignal<string | null>(null);270+271+  async function loadPendingVouched() {272+    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {273+      ok: boolean;274+      data?: PendingVouched[];275+    };276+    if (r.ok) setPendingVouched(r.data ?? []);277+  }278+  async function confirmVouched(deviceId: string) {279+    setVouchedBusy(deviceId);280+    try {281+      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });282+      await loadPendingVouched();283+    } finally {284+      setVouchedBusy(null);285+    }286+  }287+  async function rejectVouched(deviceId: string) {288+    setVouchedBusy(deviceId);289+    try {290+      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });291+      await loadPendingVouched();292+    } finally {293+      setVouchedBusy(null);294+    }295+  }296+  async function doRotateSyncIdentity() {297+    setRotateError(null);298+    setRotating(true);299+    try {300+      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {301+        ok: boolean;302+        error?: string;303+      };304+      if (!r.ok) {305+        setRotateError(r.error ?? "Rotation failed.");306+        return;307+      }308+      setRotateConfirmOpen(false);309+      // Backend set peach_recovery_pending; reuse the existing recovery-words310+      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).311+      setShowNewRecoveryWords(true);312+    } finally {313+      setRotating(false);314+    }315+  }316+261317   const [dedupCount, setDedupCount] = createSignal<number | null>(null);262318   const [dedupRunning, setDedupRunning] = createSignal(false);263319   const [dedupDone, setDedupDone] = createSignal<number | null>(null);@@ -372,6 +428,19 @@372428     // when the user navigates in. Loading it lazily keeps Settings boot fast.373429     void loadVaultHistory();374430 431+    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the432+    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.433+    void loadPendingVouched();434+    const onBroadcast = (msg: unknown) => {435+      if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {436+        void loadPendingVouched();437+      }438+    };439+    chrome.runtime?.onMessage?.addListener(onBroadcast);440+    onCleanup(() => {441+      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }442+    });443+375444     if ((licenseRes as { ok: boolean }).ok) {376445       setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);377446     }@@ -1321,9 +1390,82 @@13211390               pair13221391             </button>13231392           </div>1393+          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1394+          <Show when={pendingVouched().length > 0}>1395+            <p class="peach-settings-sub-label">1396+              PENDING DEVICES1397+              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1398+            </p>1399+            <For each={pendingVouched()}>1400+              {(dev) => (1401+                <div class="peach-settings-row peach-settings-row--top">1402+                  <div class="peach-settings-row-main">1403+                    <span>{dev.deviceName}</span>1404+                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1405+                      Was added by another of your devices. Allow it to sync with this vault?1406+                    </span>1407+                  </div>1408+                  <div style="display:flex;gap:6px;flex-shrink:0">1409+                    <button1410+                      class="peach-btn peach-btn--sm"1411+                      disabled={vouchedBusy() === dev.deviceId}1412+                      onClick={() => void confirmVouched(dev.deviceId)}1413+                    >Allow</button>1414+                    <button1415+                      class="peach-btn peach-btn--subtle peach-btn--sm"1416+                      disabled={vouchedBusy() === dev.deviceId}1417+                      onClick={() => void rejectVouched(dev.deviceId)}1418+                    >Reject</button>1419+                  </div>1420+                </div>1421+              )}1422+            </For>1423+          </Show>1424+13241425           <div class="peach-info-notice" style="margin-top:8px">13251426             Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.13261427           </div>1428+1429+          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1430+          <p class="peach-settings-sub-label">RECOVERY</p>1431+          <Show1432+            when={rotateConfirmOpen()}1433+            fallback={1434+              <div class="peach-settings-row peach-settings-row--top">1435+                <div class="peach-settings-row-main">1436+                  <span>Rotate sync identity</span>1437+                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1438+                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1439+                  </span>1440+                </div>1441+                <button1442+                  class="peach-btn peach-btn--subtle peach-btn--sm"1443+                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1444+                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1445+                >rotate</button>1446+              </div>1447+            }1448+          >1449+            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1450+              This will disconnect all paired devices — they will need to pair again — and your 12 recovery words will change. You'll be shown the new words next.1451+              <Show when={rotateError()}>1452+                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1453+              </Show>1454+              <div style="display:flex;gap:6px;margin-top:10px">1455+                <button1456+                  class="peach-btn peach-btn--subtle peach-btn--sm"1457+                  disabled={rotating()}1458+                  onClick={() => setRotateConfirmOpen(false)}1459+                >Cancel</button>1460+                <button1461+                  class="peach-btn peach-btn--sm"1462+                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1463+                  disabled={rotating()}1464+                  onClick={() => void doRotateSyncIdentity()}1465+                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1466+              </div>1467+            </div>1468+          </Show>13271469         </div>13281470       </div>13291471     );

view file