1import { Match, Show, Switch, createMemo, createSignal } from "solid-js";2import { sendMessage } from "../App";3import { RuneBackground } from "../components/RuneBackground";4import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";56type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";78export interface AdoptVaultViewProps {9 onCancel(): void;10 onDone(): void | Promise<void>;11 startAdoption(args: {12 password: string;13 14 code: string;15 confirmSAS(sas: string): Promise<boolean>;16 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;17 }): Promise<void>;18}1920const MIN_LENGTH = 12;21const MIN_ZXCVBN_SCORE = 2;222324function adoptionErrorCopy(err: unknown): string {25 const message = err instanceof Error ? err.message : String(err);26 switch (message) {27 case "invalid_code":28 return "That code wasn't accepted — it may have expired. Get a fresh one from your other device.";29 case "no_host_found":30 return "Couldn't reach your other device. Check both are on the same Wi-Fi and its pairing screen is still open.";31 case "handshake_failed":32 return "The secure connection failed. Try again with a fresh code.";33 case "adoption_rejected":34 return "Your other device declined the request.";35 default:36 return message;37 }38}3940let zxcvbnLoader: ((password: string) => { score: number }) | null = null;41async function getZxcvbn() {42 if (zxcvbnLoader) return zxcvbnLoader;43 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([44 import("@zxcvbn-ts/core"),45 import("@zxcvbn-ts/language-common"),46 import("@zxcvbn-ts/language-en"),47 ]);48 zxcvbnOptions.setOptions({49 translations: en.translations,50 graphs: common.adjacencyGraphs,51 dictionary: { ...common.dictionary, ...en.dictionary },52 });53 zxcvbnLoader = (password: string) => zxcvbn(password);54 return zxcvbnLoader;55}5657export function AdoptVaultView(props: AdoptVaultViewProps) {58 const [phase, setPhase] = createSignal<AdoptPhase>("password");59 const [password, setPassword] = createSignal("");60 const [confirmPassword, setConfirmPassword] = createSignal("");61 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);62 const [sas, setSas] = createSignal("");63 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);64 const [error, setError] = createSignal("");65 const [code, setCode] = createSignal("");66 const [connecting, setConnecting] = createSignal(false);6768 const codeReady = createMemo(() => parsePairingCode(code()) !== null);6970 createMemo(() => {71 const value = password();72 if (!value) {73 setStrengthScore(null);74 return;75 }76 getZxcvbn().then((zxcvbn) => {77 if (password() === value) setStrengthScore(zxcvbn(value).score);78 }).catch(() => {79 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);80 });81 });8283 const canContinue = createMemo(() =>84 password().length >= MIN_LENGTH &&85 confirmPassword().length >= MIN_LENGTH &&86 password() === confirmPassword() &&87 strengthScore() !== null &&88 strengthScore()! >= MIN_ZXCVBN_SCORE89 );9091 const createBlocker = createMemo(() => {92 if (!password()) return null;93 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;94 if (!confirmPassword()) return "confirm your password";95 if (password() !== confirmPassword()) return "passwords don't match";96 if (strengthScore() === null) return "checking strength...";97 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";98 return null;99 });100101 async function beginPairing() {102 if (!codeReady() || connecting()) return;103 setPhase("pair");104 setError("");105 setConnecting(true);106 try {107 await props.startAdoption({108 password: password(),109 code: code(),110 confirmSAS: (sasCode) => new Promise<boolean>((resolve) => {111 setSas(sasCode);112 setSasResolve(() => resolve);113 setPhase("sas");114 }),115 createVault: async (rootKeyHex) => {116 const res = await sendMessage({117 type: "CREATE_VAULT_FROM_ROOT_KEY",118 payload: { password: password(), rootKeyHex },119 }) as { ok: boolean; vaultId?: string; error?: string };120 if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");121 return { vaultId: res.vaultId };122 },123 });124 setPhase("success");125 } catch (err) {126 if (err instanceof Error && err.message === "sas_rejected") return;127 setError(adoptionErrorCopy(err));128 setPhase("error");129 } finally {130 setConnecting(false);131 }132 }133134 function confirmSas(ok: boolean) {135 const resolve = sasResolve();136 setSasResolve(null);137 resolve?.(ok);138 if (!ok) {139 setPhase("password");140 setPassword("");141 setConfirmPassword("");142 setStrengthScore(null);143 144 145 setCode("");146 }147 }148149 return (150 <section class="peach-screen peach-theme peach-adopt-vault">151 <RuneBackground opacity={0.07} />152 <div class="peach-adopt-card">153 <Switch>154 <Match when={phase() === "password"}>155 <h1>Set a password for this device.</h1>156 <p>This protects your vault on this device only. It can be different from your other devices.</p>157 <label class="peach-label" for="adopt-password">password</label>158 <input159 id="adopt-password"160 class="peach-underline-input peach-adopt-input"161 type="password"162 autocomplete="new-password"163 value={password()}164 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}165 />166 <label class="peach-label" for="adopt-confirm-password">confirm password</label>167 <input168 id="adopt-confirm-password"169 class="peach-underline-input peach-adopt-input"170 type="password"171 autocomplete="new-password"172 value={confirmPassword()}173 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}174 />175 <Show when={createBlocker()}>176 <p class="peach-hint">{createBlocker()}</p>177 </Show>178 <div class="peach-adopt-actions">179 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>180 Continue181 </button>182 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>183 </div>184 </Match>185186 <Match when={phase() === "pair"}>187 <div class="peach-adopt-birds" aria-hidden="true">188 <span />189 <span />190 </div>191 <h1>Pair with your other device.</h1>192 <p>193 Open Peach on the device that has your vault and tap “Pair a device”.194 Type the code it shows. Both devices need to be on the same Wi-Fi.195 </p>196 <label class="peach-label" for="adopt-pair-code">pairing code</label>197 <input198 id="adopt-pair-code"199 class="peach-underline-input peach-adopt-input peach-code-input"200 type="text"201 autocapitalize="characters"202 autocomplete="one-time-code"203 spellcheck={false}204 maxlength={32}205 placeholder="A74H-48J3-KI32-OEP4"206 aria-label="Pairing code"207 disabled={connecting()}208 value={code()}209 onInput={(event) =>210 setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32))211 }212 onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }}213 />214 <Show when={connecting()}>215 <p class="peach-hint">connecting to your other device...</p>216 </Show>217 <div class="peach-adopt-actions">218 <button219 class="peach-btn peach-btn--accent peach-btn--md"220 disabled={!codeReady() || connecting()}221 onClick={() => void beginPairing()}222 >223 Pair a device224 </button>225 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>226 </div>227 </Match>228229 <Match when={phase() === "sas"}>230 <h1>Confirm this code matches on your other device.</h1>231 <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>232 <div class="peach-adopt-actions">233 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>234 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>235 </div>236 </Match>237238 <Match when={phase() === "success"}>239 <div class="peach-adopt-birds" aria-hidden="true">240 <span />241 <span />242 </div>243 <h1>Joined your vault.</h1>244 <p>Your passwords will sync shortly.</p>245 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>246 Open Peach247 </button>248 </Match>249250 <Match when={phase() === "error"}>251 <h1>Could not join your vault.</h1>252 <p class="peach-hint peach-hint-error">{error()}</p>253 <div class="peach-adopt-actions">254 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>255 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>256 </div>257 </Match>258 </Switch>259 </div>260 </section>261 );262}