5bc0f257164c

Settings — Vault History group (10 snapshots, restore + lock) + Sync Now feedback states (v2.14.8)

time
2026-04-30 20:58 UTC · 6 months ago
machine
koh2
parent
2b121cc936d6
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+69 −0src/popup/views/Settings.tsx+160 −5
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.7",4+  "version": "2.14.8",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.7",4+  "version": "2.14.8",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": "2.14.7",3+  "version": "2.14.8",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/styles/popup.css+69 −0
@@ -6964,3 +6964,72 @@69646964   color: #9a8b7a;69656965   font-size: 14px;69666966 }6967+6968+/* Vault History — Settings → Vault sub-view */6969+6970+.peach-history-list {6971+  border: 1px solid var(--peach-border);6972+  border-radius: 8px;6973+  overflow: hidden;6974+  margin: 8px 0 4px;6975+}6976+6977+.peach-history-row {6978+  display: flex;6979+  align-items: center;6980+  justify-content: space-between;6981+  padding: 9px 12px;6982+  gap: 10px;6983+  border-bottom: 1px solid var(--peach-border);6984+}6985+6986+.peach-history-row:last-child {6987+  border-bottom: none;6988+}6989+6990+.peach-history-meta {6991+  display: flex;6992+  flex-direction: column;6993+  gap: 2px;6994+  min-width: 0;6995+  flex: 1;6996+}6997+6998+.peach-history-time {6999+  font-size: 12px;7000+  font-family: 'IBM Plex Mono', ui-monospace, monospace;7001+  color: var(--peach-t0);7002+  white-space: nowrap;7003+  overflow: hidden;7004+  text-overflow: ellipsis;7005+}7006+7007+.peach-history-sub {7008+  font-size: 10px;7009+  color: var(--peach-t3);7010+  letter-spacing: 0.06em;7011+  text-transform: uppercase;7012+  white-space: nowrap;7013+  overflow: hidden;7014+  text-overflow: ellipsis;7015+}7016+7017+.peach-history-btn {7018+  flex-shrink: 0;7019+  white-space: nowrap;7020+  min-width: 56px;7021+  text-align: center;7022+}7023+7024+.peach-history-btn.is-busy {7025+  opacity: 0.65;7026+}7027+7028+.peach-history-footer {7029+  font-size: 10px;7030+  color: var(--peach-t3);7031+  letter-spacing: 0.05em;7032+  text-align: right;7033+  margin: 0;7034+  padding: 2px 0 0;7035+}

view file

src/popup/views/Settings.tsx+160 −5
@@ -6,7 +6,12 @@66 import { biometricAvailable } from "../../shared/biometric";77 import type { Canary } from "../../shared/types";88 import { ACCENT_COLORS } from "../../shared/types";9-import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";9+import type {10+  AccentColorName,11+  PasskeyProviderState,12+  ProFeature,13+  VaultSnapshot,14+} from "../../shared/types";1015 import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";1116 import {1217   DEFAULT_BINDINGS,@@ -121,6 +126,9 @@121126   const [syncProStatus, setSyncProStatus] = createSignal(false);122127   const isPro = () => props.isPro ?? syncProStatus();123128   const [syncingNow, setSyncingNow] = createSignal(false);129+  const [syncFeedback, setSyncFeedback] = createSignal<130+    { kind: "ok" | "err"; text: string } | null131+  >(null);124132   const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);125133   const [passkeySaving, setPasskeySaving] = createSignal(false);126134 @@ -130,6 +138,64 @@130138 131139   const [declinedCardCount, setDeclinedCardCount] = createSignal(0);132140 141+  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted142+  // BEFORE every real-mode vault write so the user can roll back if a sync143+  // or import overwrites the wrong state.144+  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);145+  const [historyLoading, setHistoryLoading] = createSignal(false);146+  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);147+148+  async function loadVaultHistory() {149+    setHistoryLoading(true);150+    try {151+      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });152+      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];153+      setHistorySnapshots(list);154+    } finally {155+      setHistoryLoading(false);156+    }157+  }158+159+  async function handleRestoreVaultSnapshot(id: string) {160+    if (historyRestoring()) return;161+    setHistoryRestoring(id);162+    const res = await sendMessage({163+      type: "RESTORE_VAULT_SNAPSHOT",164+      payload: { snapshotId: id },165+    });166+    setHistoryRestoring(null);167+    if ((res as { ok: boolean }).ok) {168+      // Vault is now locked — let the parent close Settings; App.tsx's169+      // VAULT_LOCKED listener will route back to the lock screen.170+      props.onBack();171+    }172+  }173+174+  function formatHistoryTime(savedAt: number): string {175+    const diff = Date.now() - savedAt;176+    if (diff < 60_000) return "Just now";177+    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;178+    const d = new Date(savedAt);179+    const isToday = new Date().toDateString() === d.toDateString();180+    const isYest =181+      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();182+    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });183+    if (isToday) return `Today ${t}`;184+    if (isYest) return `Yesterday ${t}`;185+    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;186+  }187+188+  function reasonLabel(reason: VaultSnapshot["reason"]): string {189+    return (190+      {191+        local_save: "saved",192+        sync_received: "synced",193+        import: "imported",194+        restore: "restored",195+      } as const196+    )[reason] ?? reason;197+  }198+133199   const [showChangePw, setShowChangePw] = createSignal(false);134200   const [changePwCurrent, setChangePwCurrent] = createSignal("");135201   const [changePwNew, setChangePwNew] = createSignal("");@@ -182,6 +248,10 @@182248       sendMessage({ type: "GET_LICENSE_KEY" }),183249     ]);184250 251+    // Vault history loads in the background — the Vault sub-view picks it up252+    // when the user navigates in. Loading it lazily keeps Settings boot fast.253+    void loadVaultHistory();254+185255     if ((licenseRes as { ok: boolean }).ok) {186256       setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);187257     }@@ -339,8 +409,28 @@339409   async function handleSyncNow() {340410     if (syncStatus() === "not_trusted") return;341411     setSyncingNow(true);342-    await sendMessage({ type: "TRIGGER_SYNC" });343-    setTimeout(() => setSyncingNow(false), 3000);412+    setSyncFeedback(null);413+    const res = await sendMessage({ type: "TRIGGER_SYNC" });414+    const ok = (res as { ok: boolean }).ok;415+    if (ok) {416+      const data = (res as { data?: { syncedCount?: number; totalReachable?: number } }).data;417+      const count = data?.syncedCount ?? 0;418+      setSyncFeedback({ kind: "ok", text: count > 0 ? `Synced with ${count} ${count === 1 ? "device" : "devices"}` : "Up to date" });419+      setLastSyncAt(Date.now());420+    } else {421+      const error = (res as { error?: string }).error;422+      const map: Record<string, string> = {423+        no_paired_devices: "No paired devices",424+        no_devices_reachable: "No devices found",425+        vault_locked: "Vault is locked",426+        pro_required: "Sync requires Pro",427+      };428+      setSyncFeedback({ kind: "err", text: map[error ?? ""] ?? "Sync failed" });429+    }430+    setTimeout(() => {431+      setSyncingNow(false);432+      setSyncFeedback(null);433+    }, 3500);344434   }345435 346436   async function handleRemoveDevice(deviceId: string) {@@ -1017,6 +1107,61 @@10171107               open10181108             </button>10191109           </div>1110+1111+          <p class="peach-settings-sub-label">VAULT HISTORY</p>1112+          <Show when={historyLoading()}>1113+            <p class="peach-settings-hint">Loading…</p>1114+          </Show>1115+          <Show when={!historyLoading() && historySnapshots().length === 0}>1116+            <p class="peach-settings-hint">1117+              No snapshots yet. Peach saves automatically before every change —1118+              saves, syncs, and imports.1119+            </p>1120+          </Show>1121+          <Show when={!historyLoading() && historySnapshots().length > 0}>1122+            <p class="peach-settings-hint">1123+              Restoring locks your vault. Unlock with the password that1124+              encrypted that snapshot to confirm.1125+            </p>1126+            <div class="peach-history-list">1127+              <For each={historySnapshots()}>1128+                {(snap) => (1129+                  <div class="peach-history-row">1130+                    <div class="peach-history-meta">1131+                      <span class="peach-history-time">1132+                        {formatHistoryTime(snap.savedAt)}1133+                      </span>1134+                      <span class="peach-history-sub">1135+                        {[1136+                          snap.entryCount !== null1137+                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1138+                            : null,1139+                          reasonLabel(snap.reason),1140+                        ]1141+                          .filter(Boolean)1142+                          .join(" · ")}1143+                      </span>1144+                    </div>1145+                    <button1146+                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1147+                        historyRestoring() === snap.id ? " is-busy" : ""1148+                      }`}1149+                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1150+                      disabled={historyRestoring() !== null}1151+                    >1152+                      <Show when={historyRestoring() === snap.id} fallback="Restore">1153+                        …1154+                      </Show>1155+                    </button>1156+                  </div>1157+                )}1158+              </For>1159+            </div>1160+            <p class="peach-history-footer">1161+              {historySnapshots().length} of 10 snapshots1162+            </p>1163+          </Show>1164+10201165           <div class="peach-danger-card">10211166             <div class="peach-danger-card-head">10221167               <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>@@ -1139,11 +1284,21 @@11391284             <button11401285               class="peach-btn peach-btn--subtle peach-btn--sm"11411286               onClick={() => void handleSyncNow()}1142-              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1287+              disabled={syncStatus() === "not_trusted" || syncingNow()}11431288             >1144-              {syncingNow() ? "Syncing..." : "Sync now"}1289+              <Switch>1290+                <Match when={syncingNow()}>Syncing…</Match>1291+                <Match when={syncFeedback()?.kind === "ok"}>✓ {syncFeedback()!.text}</Match>1292+                <Match when={syncFeedback()?.kind === "err"}>{syncFeedback()!.text}</Match>1293+                <Match when={true}>Sync now</Match>1294+              </Switch>11451295             </button>11461296           </div>1297+          <p class="peach-settings-hint">1298+            If you edit on multiple devices before syncing, the most recently1299+            changed device wins. Use Vault History above to recover older1300+            versions if needed.1301+          </p>11471302 11481303           <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">11491304             <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>

view file