1import { Match, Show, Switch, createMemo, createSignal } from "solid-js";2import { sendMessage } from "../App";3import { RuneBackground } from "../components/RuneBackground";45type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";67export interface AdoptVaultViewProps {8 onCancel(): void;9 onDone(): void | Promise<void>;10 startAdoption(args: {11 password: string;12 confirmSAS(sas: string): Promise<boolean>;13 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;14 }): Promise<void>;15}1617const MIN_LENGTH = 12;18const MIN_ZXCVBN_SCORE = 2;1920let zxcvbnLoader: ((password: string) => { score: number }) | null = null;21async function getZxcvbn() {22 if (zxcvbnLoader) return zxcvbnLoader;23 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([24 import("@zxcvbn-ts/core"),25 import("@zxcvbn-ts/language-common"),26 import("@zxcvbn-ts/language-en"),27 ]);28 zxcvbnOptions.setOptions({29 translations: en.translations,30 graphs: common.adjacencyGraphs,31 dictionary: { ...common.dictionary, ...en.dictionary },32 });33 zxcvbnLoader = (password: string) => zxcvbn(password);34 return zxcvbnLoader;35}3637export function AdoptVaultView(props: AdoptVaultViewProps) {38 const [phase, setPhase] = createSignal<AdoptPhase>("password");39 const [password, setPassword] = createSignal("");40 const [confirmPassword, setConfirmPassword] = createSignal("");41 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);42 const [sas, setSas] = createSignal("");43 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);44 const [error, setError] = createSignal("");4546 createMemo(() => {47 const value = password();48 if (!value) {49 setStrengthScore(null);50 return;51 }52 getZxcvbn().then((zxcvbn) => {53 if (password() === value) setStrengthScore(zxcvbn(value).score);54 }).catch(() => {55 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);56 });57 });5859 const canContinue = createMemo(() =>60 password().length >= MIN_LENGTH &&61 confirmPassword().length >= MIN_LENGTH &&62 password() === confirmPassword() &&63 strengthScore() !== null &&64 strengthScore()! >= MIN_ZXCVBN_SCORE65 );6667 const createBlocker = createMemo(() => {68 if (!password()) return null;69 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;70 if (!confirmPassword()) return "confirm your password";71 if (password() !== confirmPassword()) return "passwords don't match";72 if (strengthScore() === null) return "checking strength...";73 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";74 return null;75 });7677 async function beginPairing() {78 setPhase("pair");79 setError("");80 try {81 await props.startAdoption({82 password: password(),83 confirmSAS: (code) => new Promise<boolean>((resolve) => {84 setSas(code);85 setSasResolve(() => resolve);86 setPhase("sas");87 }),88 createVault: async (rootKeyHex) => {89 const res = await sendMessage({90 type: "CREATE_VAULT_FROM_ROOT_KEY",91 payload: { password: password(), rootKeyHex },92 }) as { ok: boolean; vaultId?: string; error?: string };93 if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");94 return { vaultId: res.vaultId };95 },96 });97 setPhase("success");98 } catch (err) {99 if (err instanceof Error && err.message === "sas_rejected") return;100 setError(err instanceof Error ? err.message : String(err));101 setPhase("error");102 }103 }104105 function confirmSas(ok: boolean) {106 const resolve = sasResolve();107 setSasResolve(null);108 resolve?.(ok);109 if (!ok) {110 setPhase("password");111 setPassword("");112 setConfirmPassword("");113 setStrengthScore(null);114 }115 }116117 return (118 <section class="peach-screen peach-theme peach-adopt-vault">119 <RuneBackground opacity={0.07} />120 <div class="peach-adopt-card">121 <Switch>122 <Match when={phase() === "password"}>123 <h1>Set a password for this device.</h1>124 <p>This protects your vault on this device only. It can be different from your other devices.</p>125 <label class="peach-label" for="adopt-password">password</label>126 <input127 id="adopt-password"128 class="peach-underline-input peach-adopt-input"129 type="password"130 autocomplete="new-password"131 value={password()}132 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}133 />134 <label class="peach-label" for="adopt-confirm-password">confirm password</label>135 <input136 id="adopt-confirm-password"137 class="peach-underline-input peach-adopt-input"138 type="password"139 autocomplete="new-password"140 value={confirmPassword()}141 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}142 />143 <Show when={createBlocker()}>144 <p class="peach-hint">{createBlocker()}</p>145 </Show>146 <div class="peach-adopt-actions">147 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>148 Continue149 </button>150 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>151 </div>152 </Match>153154 <Match when={phase() === "pair"}>155 <div class="peach-adopt-birds" aria-hidden="true">156 <span />157 <span />158 </div>159 <h1>Pair with your other device.</h1>160 <p>Open Peach on the device that has your vault. Bring both devices close together.</p>161 <div class="peach-adopt-actions">162 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void beginPairing()}>163 Pair a device164 </button>165 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>166 </div>167 </Match>168169 <Match when={phase() === "sas"}>170 <h1>Confirm this code matches on your other device.</h1>171 <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>172 <div class="peach-adopt-actions">173 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>174 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>175 </div>176 </Match>177178 <Match when={phase() === "success"}>179 <div class="peach-adopt-birds" aria-hidden="true">180 <span />181 <span />182 </div>183 <h1>Joined your vault.</h1>184 <p>Your passwords will sync shortly.</p>185 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>186 Open Peach187 </button>188 </Match>189190 <Match when={phase() === "error"}>191 <h1>Could not join your vault.</h1>192 <p class="peach-hint peach-hint-error">{error()}</p>193 <div class="peach-adopt-actions">194 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>195 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>196 </div>197 </Match>198 </Switch>199 </div>200 </section>201 );202}