1import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";23import { sendMessage } from "../App";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { ImportView } from "./Import";678let _zxcvbn: ((password: string) => { score: number }) | null = null;9async function getZxcvbn() {10 if (_zxcvbn) return _zxcvbn;11 const mod = await import("zxcvbn");12 _zxcvbn = mod.default;13 return _zxcvbn;14}1516interface LockProps {17 hasVault: boolean;18 ready: boolean;19 isPro?: boolean;20 onUnlock: () => void | Promise<void>;21}222324type RestoreSurface = "restore" | "snapshots" | "peachscript" | "file" | null;2526export function LockView(props: LockProps) {27 const [password, setPassword] = createSignal("");28 const [confirmPassword, setConfirmPassword] = createSignal("");29 const [error, setError] = createSignal("");30 const [loading, setLoading] = createSignal(false);31 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);32 const [showPassword, setShowPassword] = createSignal(false);3334 const MIN_LENGTH = 12;35 const MIN_ZXCVBN_SCORE = 2;3637 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);3839 const canCreate = createMemo(40 () =>41 password().length >= MIN_LENGTH &&42 confirmPassword().length >= MIN_LENGTH &&43 password() === confirmPassword() &&44 strengthScore() !== null &&45 strengthScore()! >= MIN_ZXCVBN_SCORE46 );4748 const passwordStrength = createMemo(() => {49 const score = strengthScore();50 if (score === null) return null;51 if (score <= 1) return "weak";52 if (score === 2) return "fair";53 if (score === 3) return "good";54 return "strong";55 });5657 58 createMemo(() => {59 const value = password();60 if (!value) { setStrengthScore(null); return; }61 getZxcvbn().then((zxcvbn) => {62 if (password() === value) setStrengthScore(zxcvbn(value).score);63 }).catch(() => {64 65 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);66 });67 });6869 async function handleUnlock() {70 if (!password().trim()) return;71 setLoading(true);72 setError("");73 const response = await sendMessage({74 type: "UNLOCK",75 payload: { password: password() },76 });77 const result = response as { ok: boolean; error?: string };78 if (result.ok) {79 await props.onUnlock();80 setLoading(false);81 return;82 }83 setError(getDisplayError(result.error, "incorrect password"));84 setPassword("");85 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;86 passwordField?.classList.add("shake");87 setTimeout(() => passwordField?.classList.remove("shake"), 300);88 setLoading(false);89 }9091 async function handleCreate() {92 if (!password().trim()) return;93 if (password().length < MIN_LENGTH) {94 setError(`password must be at least ${MIN_LENGTH} characters`);95 return;96 }97 if (password() !== confirmPassword()) {98 setError("passwords do not match");99 return;100 }101 const score = strengthScore();102 if (score === null || score < MIN_ZXCVBN_SCORE) {103 setError("password is too weak — try a longer or more varied passphrase");104 return;105 }106 setLoading(true);107 setError("");108 const response = await sendMessage({109 type: "CREATE_VAULT",110 payload: { password: password() },111 });112 const result = response as { ok: boolean; error?: string };113 if (result.ok) {114 await props.onUnlock();115 setLoading(false);116 return;117 }118 setError(getDisplayError(result.error, "failed to create vault"));119 setLoading(false);120 }121122 function openRestore(surface: Exclude<RestoreSurface, null>) {123 if (!props.ready || loading()) return;124 setPassword("");125 setConfirmPassword("");126 setError("");127 setRestoreSurface(surface);128 }129130 131 return (132 <Switch>133 {}134 <Match when={restoreSurface() === "restore"}>135 <RestorePickerView136 onBack={() => setRestoreSurface(null)}137 onPickPeachScript={() => setRestoreSurface("peachscript")}138 onPickFile={() => setRestoreSurface("file")}139 onPickSnapshots={() => setRestoreSurface("snapshots")}140 />141 </Match>142143 {}144 <Match when={restoreSurface() === "snapshots"}>145 <SnapshotRestoreView146 onBack={() => setRestoreSurface("restore")}147 onRestored={props.onUnlock}148 />149 </Match>150151 {}152 <Match when={restoreSurface() === "peachscript"}>153 <ImportView154 mode="onboarding"155 replacesExistingVault={props.hasVault}156 initialView="peachscript"157 onBack={() => setRestoreSurface("restore")}158 onImport={async () => {159 setRestoreSurface(null);160 await props.onUnlock();161 }}162 />163 </Match>164165 {}166 <Match when={restoreSurface() === "file"}>167 <ImportView168 mode="onboarding"169 replacesExistingVault={props.hasVault}170 initialView="file"171 onBack={() => setRestoreSurface("restore")}172 onImport={async () => {173 setRestoreSurface(null);174 await props.onUnlock();175 }}176 />177 </Match>178179 {}180 <Match when={props.hasVault}>181 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">182 <header class="peach-screen-header lock-screen-header">183 <div class="peach-wordmark-stack">184 <PeachWordmark class="peach-wordmark-wrap" />185 <Show when={props.isPro}>186 <span class="peach-pro-mini-badge">pro</span>187 </Show>188 </div>189 </header>190191 <div class="peach-lock-hero peach-lock-hero--unlock">192 <div class="peach-lock-hero-art" aria-hidden="true">193 <PeachMark class="peach-lock-hero-mark" />194 </div>195 <span class="peach-lock-eyebrow">vault locked</span>196 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">197 unlock198 <br />199 your vault.200 </h1>201 </div>202203 <section class="peach-lock-panel peach-lock-panel--unlock">204 <label class="peach-label" for="unlock-password">205 master password206 </label>207 <input208 id="unlock-password"209 class="peach-underline-input peach-unlock-input lock-password-input"210 type="password"211 placeholder="••••••••••••"212 autocomplete="current-password"213 value={password()}214 disabled={!props.ready}215 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}216 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}217 onKeyDown={(event) => {218 if (event.key !== "Enter" || !props.ready) return;219 void handleUnlock();220 }}221 />222223 <Show when={error()}>224 <p class="peach-hint peach-hint-error">{error()}</p>225 </Show>226227 <button228 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"229 onClick={() => void handleUnlock()}230 disabled={!props.ready || loading()}231 >232 {loading() ? "unlocking..." : "unlock"}233 </button>234 </section>235 </section>236 </Match>237238 {}239 <Match when={true}>240 <section241 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"242 data-motion-phase="enter"243 >244 <header class="peach-screen-header peach-newvault-header">245 <div class="peach-wordmark-stack">246 <PeachWordmark class="peach-wordmark-wrap" />247 <Show when={props.isPro}>248 <span class="peach-pro-mini-badge">pro</span>249 </Show>250 </div>251 </header>252253 <div class="peach-newvault-mark" aria-hidden="true">254 <PeachMark class="peach-newvault-mark-art" />255 </div>256257 <div class="peach-newvault-card">258 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>259 <hr class="peach-newvault-divider" />260261 <div class="peach-newvault-field">262 <div class="peach-newvault-label-row">263 <label class="peach-label" for="master-password">264 master password265 </label>266 <button267 class="peach-lock-reveal-btn"268 type="button"269 onClick={() => setShowPassword((value) => !value)}270 aria-label={showPassword() ? "Hide password" : "Show password"}271 >272 <Show273 when={showPassword()}274 fallback={275 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">276 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />277 <circle cx="7" cy="5" r="1.75" />278 </svg>279 }280 >281 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">282 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />283 <path d="M3.5 5.5L2.5 7.5" />284 <path d="M7 6.5V8.5" />285 <path d="M10.5 5.5L11.5 7.5" />286 </svg>287 </Show>288 {showPassword() ? "hide" : "show"}289 </button>290 </div>291 <input292 id="master-password"293 class="peach-underline-input peach-newvault-input lock-password-input"294 type={showPassword() ? "text" : "password"}295 placeholder="••••••••••••"296 autocomplete="new-password"297 value={password()}298 disabled={!props.ready}299 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}300 onKeyDown={(event) => {301 if (event.key !== "Enter" || !props.ready) return;302 void handleCreate();303 }}304 />305 <Show306 when={password().length > 0 && passwordStrength() !== null}307 fallback={308 <p class="peach-newvault-hint">at least 12 characters</p>309 }310 >311 <div class="peach-password-strength">312 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>313 <div class="peach-strength-fill"></div>314 </div>315 <span class="peach-strength-label">{passwordStrength()}</span>316 </div>317 </Show>318 </div>319320 <div class="peach-newvault-field">321 <label class="peach-label" for="confirm-password">322 confirm password323 </label>324 <input325 id="confirm-password"326 class="peach-underline-input peach-newvault-input"327 type={showPassword() ? "text" : "password"}328 placeholder="••••••••••••"329 autocomplete="new-password"330 value={confirmPassword()}331 disabled={!props.ready}332 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}333 onKeyDown={(event) => {334 if (event.key === "Enter" && props.ready) void handleCreate();335 }}336 />337 </div>338339 <Show when={error()}>340 <p class="peach-hint peach-hint-error">{error()}</p>341 </Show>342343 <button344 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"345 onClick={() => void handleCreate()}346 disabled={!props.ready || loading() || !canCreate()}347 >348 {loading() ? "creating..." : "create vault"}349 </button>350351 <p class="peach-newvault-warning">352 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>353 <span>If you forget it, your vault cannot be opened. There is no reset.</span>354 </p>355 </div>356357 <div class="peach-newvault-footer">358 <p class="peach-hint">Already have a backup?</p>359 <button360 class="peach-lock-restore-link"361 onClick={() => openRestore("restore")}362 disabled={!props.ready}363 >364 › restore from backup365 </button>366 </div>367 </section>368 </Match>369 </Switch>370 );371}372373374375function RestorePickerView(props: {376 onBack: () => void;377 onPickPeachScript: () => void;378 onPickFile: () => void;379 onPickSnapshots: () => void;380}) {381 return (382 <section383 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"384 data-motion-phase="enter"385 >386 <header class="peach-screen-header lock-screen-header">387 <PeachWordmark class="peach-wordmark-wrap" />388 <button class="peach-lock-restore-link" onClick={props.onBack}>389 ‹ back390 </button>391 </header>392393 <div class="peach-lock-hero">394 <span class="peach-lock-eyebrow">restore</span>395 <h1 class="peach-onboarding-headline">396 bring back397 <br />398 your vault.399 </h1>400 </div>401402 <section class="peach-lock-panel peach-lock-panel--restore">403 <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>404405 <button class="peach-lock-option" onClick={props.onPickPeachScript}>406 <span class="peach-lock-option-title">peachscript</span>407 <span class="peach-lock-option-copy">408 A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.409 </span>410 </button>411412 <button class="peach-lock-option" onClick={props.onPickFile}>413 <span class="peach-lock-option-title">backup file</span>414 <span class="peach-lock-option-copy">415 A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).416 </span>417 </button>418419 <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>420 <span class="peach-lock-option-title">vault history</span>421 <span class="peach-lock-option-copy">422 Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.423 </span>424 </button>425 </section>426 </section>427 );428}429430431432interface SnapshotSummary {433 index: number;434 timestamp: number;435 entryCount: number;436}437438function SnapshotRestoreView(props: {439 onBack: () => void;440 onRestored: () => void | Promise<void>;441}) {442 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);443 const [loading, setLoading] = createSignal(true);444 const [selected, setSelected] = createSignal<number | null>(null);445 const [password, setPassword] = createSignal("");446 const [confirming, setConfirming] = createSignal(false);447 const [restoring, setRestoring] = createSignal(false);448 const [error, setError] = createSignal("");449450 onMount(async () => {451 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });452 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);453 setLoading(false);454 });455456 function formatAge(ts: number): string {457 const diff = Date.now() - ts;458 const mins = Math.floor(diff / 60_000);459 if (mins < 2) return "just now";460 if (mins < 60) return `${mins} minutes ago`;461 const hours = Math.floor(mins / 60);462 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;463 const days = Math.floor(hours / 24);464 return `${days} day${days === 1 ? "" : "s"} ago`;465 }466467 function selectSnapshot(index: number) {468 setSelected(index);469 setConfirming(false);470 setPassword("");471 setError("");472 }473474 async function handleRestore() {475 const idx = selected();476 if (idx === null || !password()) return;477 setRestoring(true);478 setError("");479 const res = await sendMessage({480 type: "RESTORE_VAULT_SNAPSHOT",481 payload: { index: idx, password: password() },482 });483 if ((res as { ok: boolean }).ok) {484 await props.onRestored();485 } else {486 setError("Incorrect password, or this snapshot cannot be decrypted.");487 setRestoring(false);488 }489 }490491 const selectedSnap = () => {492 const idx = selected();493 return idx !== null ? (snapshots()[idx] ?? null) : null;494 };495496 return (497 <section498 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"499 data-motion-phase="enter"500 >501 <header class="peach-screen-header lock-screen-header">502 <PeachWordmark class="peach-wordmark-wrap" />503 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>504 ‹ back505 </button>506 </header>507508 <div class="peach-lock-hero">509 <span class="peach-lock-eyebrow">vault history</span>510 <h1 class="peach-onboarding-headline">511 revert to512 <br />513 a snapshot.514 </h1>515 </div>516517 <section class="peach-lock-panel peach-snapshot-panel">518 <Show when={loading()}>519 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>520 </Show>521522 <Show when={!loading() && snapshots().length === 0}>523 <p class="peach-hint" style="line-height:1.6;">524 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.525 </p>526 </Show>527528 <Show when={!loading() && snapshots().length > 0}>529 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>530 <div class="peach-snapshot-list">531 <For each={snapshots()}>532 {(snap) => (533 <button534 class="peach-snapshot-row"535 classList={{ "is-selected": selected() === snap.index }}536 onClick={() => selectSnapshot(snap.index)}537 disabled={restoring()}538 >539 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>540 <span class="peach-snapshot-count">541 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}542 </span>543 </button>544 )}545 </For>546 </div>547548 <Show when={selected() !== null}>549 <div class="peach-snapshot-confirm">550 <label class="peach-label" for="snapshot-pw">master password</label>551 <input552 id="snapshot-pw"553 class="peach-underline-input peach-unlock-input"554 type="password"555 placeholder="••••••••••••"556 autocomplete="current-password"557 value={password()}558 disabled={restoring()}559 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}560 onKeyDown={(e) => {561 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();562 }}563 />564565 <Show when={error()}>566 <p class="peach-hint peach-hint-error">{error()}</p>567 </Show>568569 <Show570 when={confirming()}571 fallback={572 <button573 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"574 onClick={() => setConfirming(true)}575 disabled={!password().trim() || restoring()}576 >577 restore this version →578 </button>579 }580 >581 <div class="peach-snapshot-warning">582 <svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="rgba(220,80,60,0.9)" stroke-width="1.4" stroke-linecap="round">583 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />584 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />585 </svg>586 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.587 </div>588 <div class="peach-snapshot-confirm-row">589 <button590 class="peach-btn peach-btn--accent peach-btn--sm"591 style="flex:1"592 onClick={() => void handleRestore()}593 disabled={restoring() || !password().trim()}594 >595 {restoring() ? "restoring…" : "yes, restore"}596 </button>597 <button598 class="peach-btn peach-btn--ghost peach-btn--sm"599 onClick={() => setConfirming(false)}600 disabled={restoring()}601 >602 cancel603 </button>604 </div>605 </Show>606 </div>607 </Show>608 </Show>609 </section>610 </section>611 );612}613614615616function getDisplayError(error: string | undefined, fallback: string): string {617 if (!error) return fallback;618 if (619 error === "SERVICE_WORKER_UNAVAILABLE" ||620 error.includes("Receiving end does not exist") ||621 error.includes("message port closed")622 ) {623 return "background service unavailable";624 }625 return error;626}