import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js"; import { sendMessage } from "../App"; import { PeachMark, PeachWordmark } from "../components/PeachMark"; import { CJKImportView } from "./CJKImport"; import { ImportView } from "./Import"; // Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation let _zxcvbn: ((password: string) => { score: number }) | null = null; async function getZxcvbn() { if (_zxcvbn) return _zxcvbn; const mod = await import("zxcvbn"); _zxcvbn = mod.default; return _zxcvbn; } interface LockProps { hasVault: boolean; ready: boolean; onUnlock: () => void | Promise; } // "restore" = picker, "snapshots" = vault history, "cjk" / "file" = detail flows type RestoreSurface = "restore" | "snapshots" | "cjk" | "file" | null; export function LockView(props: LockProps) { const [password, setPassword] = createSignal(""); const [confirmPassword, setConfirmPassword] = createSignal(""); const [error, setError] = createSignal(""); const [loading, setLoading] = createSignal(false); const [restoreSurface, setRestoreSurface] = createSignal(null); const [showPassword, setShowPassword] = createSignal(false); const MIN_LENGTH = 12; const MIN_ZXCVBN_SCORE = 2; const [strengthScore, setStrengthScore] = createSignal(null); const canCreate = createMemo( () => password().length >= MIN_LENGTH && confirmPassword().length >= MIN_LENGTH && password() === confirmPassword() && strengthScore() !== null && strengthScore()! >= MIN_ZXCVBN_SCORE ); const passwordStrength = createMemo(() => { const score = strengthScore(); if (score === null) return null; if (score <= 1) return "weak"; if (score === 2) return "fair"; if (score === 3) return "good"; return "strong"; }); // Update strength asynchronously when password changes createMemo(() => { const value = password(); if (!value) { setStrengthScore(null); return; } getZxcvbn().then((zxcvbn) => { if (password() === value) setStrengthScore(zxcvbn(value).score); }).catch(() => { // zxcvbn unavailable — fall back to length-only heuristic if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1); }); }); async function handleUnlock() { if (!password().trim()) return; setLoading(true); setError(""); const response = await sendMessage({ type: "UNLOCK", payload: { password: password() }, }); const result = response as { ok: boolean; error?: string }; if (result.ok) { await props.onUnlock(); setLoading(false); return; } setError(getDisplayError(result.error, "incorrect password")); setPassword(""); const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined; passwordField?.classList.add("shake"); setTimeout(() => passwordField?.classList.remove("shake"), 300); setLoading(false); } async function handleCreate() { if (!password().trim()) return; if (password().length < MIN_LENGTH) { setError(`password must be at least ${MIN_LENGTH} characters`); return; } if (password() !== confirmPassword()) { setError("passwords do not match"); return; } const score = strengthScore(); if (score === null || score < MIN_ZXCVBN_SCORE) { setError("password is too weak — try a longer or more varied passphrase"); return; } setLoading(true); setError(""); const response = await sendMessage({ type: "CREATE_VAULT", payload: { password: password() }, }); const result = response as { ok: boolean; error?: string }; if (result.ok) { await props.onUnlock(); setLoading(false); return; } setError(getDisplayError(result.error, "failed to create vault")); setLoading(false); } function openRestore(surface: Exclude) { if (!props.ready || loading()) return; setPassword(""); setConfirmPassword(""); setError(""); setRestoreSurface(surface); } // ── All surfaces routed reactively via Switch/Match ─────────────────── return ( {/* ── Restore picker ────────────────────────────────────────── */} setRestoreSurface(null)} onPickCjk={() => setRestoreSurface("cjk")} onPickFile={() => setRestoreSurface("file")} onPickSnapshots={() => setRestoreSurface("snapshots")} /> {/* ── Vault snapshot history ────────────────────────────────── */} setRestoreSurface("restore")} onRestored={props.onUnlock} /> {/* ── CJK restore ───────────────────────────────────────────── */} setRestoreSurface("restore")} onDone={async () => { setRestoreSurface(null); await props.onUnlock(); }} /> {/* ── File restore ──────────────────────────────────────────── */} setRestoreSurface("restore")} onImport={async () => { setRestoreSurface(null); await props.onUnlock(); }} /> {/* ── Unlock (hasVault = true) ───────────────────────────────── */}
vault locked

unlock
your vault.

{ if (props.ready) setTimeout(() => el.focus(), 80); }} onInput={(event) => setPassword((event.target as HTMLInputElement).value)} onKeyDown={(event) => { if (event.key !== "Enter" || !props.ready) return; void handleUnlock(); }} />

{error()}

{/* ── Onboarding (hasVault = false) ─────────────────────────── */}
new vault

keep your
secrets.

setPassword((event.target as HTMLInputElement).value)} onKeyDown={(event) => { if (event.key !== "Enter" || !props.ready) return; void handleCreate(); }} /> 0 && passwordStrength() !== null}>
{passwordStrength()}
setConfirmPassword((event.target as HTMLInputElement).value)} onKeyDown={(event) => { if (event.key === "Enter" && props.ready) void handleCreate(); }} />

{error()}

If you lose this password, Peach cannot recover your vault. There is no reset.

); } // ── Restore method picker ────────────────────────────────────────────────── function RestorePickerView(props: { onBack: () => void; onPickCjk: () => void; onPickFile: () => void; onPickSnapshots: () => void; }) { return (
restore

bring back
your vault.

How did you back up your data?

); } // ── Vault snapshot restore ───────────────────────────────────────────────── interface SnapshotSummary { index: number; timestamp: number; entryCount: number; } function SnapshotRestoreView(props: { onBack: () => void; onRestored: () => void | Promise; }) { const [snapshots, setSnapshots] = createSignal([]); const [loading, setLoading] = createSignal(true); const [selected, setSelected] = createSignal(null); const [password, setPassword] = createSignal(""); const [confirming, setConfirming] = createSignal(false); const [restoring, setRestoring] = createSignal(false); const [error, setError] = createSignal(""); onMount(async () => { const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" }); setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []); setLoading(false); }); function formatAge(ts: number): string { const diff = Date.now() - ts; const mins = Math.floor(diff / 60_000); if (mins < 2) return "just now"; if (mins < 60) return `${mins} minutes ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; const days = Math.floor(hours / 24); return `${days} day${days === 1 ? "" : "s"} ago`; } function selectSnapshot(index: number) { setSelected(index); setConfirming(false); setPassword(""); setError(""); } async function handleRestore() { const idx = selected(); if (idx === null || !password()) return; setRestoring(true); setError(""); const res = await sendMessage({ type: "RESTORE_VAULT_SNAPSHOT", payload: { index: idx, password: password() }, }); if ((res as { ok: boolean }).ok) { await props.onRestored(); } else { setError("Incorrect password, or this snapshot cannot be decrypted."); setRestoring(false); } } const selectedSnap = () => { const idx = selected(); return idx !== null ? (snapshots()[idx] ?? null) : null; }; return (
vault history

revert to
a snapshot.

Loading…

No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.

0}>

Select a version to restore:

{(snap) => ( )}
setPassword((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter" && !restoring() && confirming()) void handleRestore(); }} />

{error()}

setConfirming(true)} disabled={!password().trim() || restoring()} > restore this version → } >
This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.
); } // ── Helpers ──────────────────────────────────────────────────────────────── function getDisplayError(error: string | undefined, fallback: string): string { if (!error) return fallback; if ( error === "SERVICE_WORKER_UNAVAILABLE" || error.includes("Receiving end does not exist") || error.includes("message port closed") ) { return "background service unavailable"; } return error; }