1import {2 Match,3 Show,4 Switch,5 createEffect,6 createMemo,7 createSignal,8 onCleanup,9} from "solid-js";10import { sendMessage } from "../App";11import { RuneBackground } from "../components/RuneBackground";12import { PairingQrCode } from "../components/PairingQrCode";13import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1415type AdoptPhase = "password" | "pair";1617export interface AdoptVaultViewProps {18 onCancel(): void;19 onDone(): void | Promise<void>;20}2122const MIN_LENGTH = 12;23const MIN_ZXCVBN_SCORE = 2;2425let zxcvbnLoader: ((password: string) => { score: number }) | null = null;26async function getZxcvbn() {27 if (zxcvbnLoader) return zxcvbnLoader;28 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([29 import("@zxcvbn-ts/core"),30 import("@zxcvbn-ts/language-common"),31 import("@zxcvbn-ts/language-en"),32 ]);33 zxcvbnOptions.setOptions({34 translations: en.translations,35 graphs: common.adjacencyGraphs,36 dictionary: { ...common.dictionary, ...en.dictionary },37 });38 zxcvbnLoader = (password: string) => zxcvbn(password);39 return zxcvbnLoader;40}4142export function AdoptVaultView(props: AdoptVaultViewProps) {43 const [phase, setPhase] = createSignal<AdoptPhase>("password");44 const [password, setPassword] = createSignal("");45 const [confirmPassword, setConfirmPassword] = createSignal("");46 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);47 const [receiverInvitation, setReceiverInvitation] = createSignal("");48 const [receiverError, setReceiverError] = createSignal("");4950 onCleanup(() => {51 52 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);53 });5455 56 57 58 59 60 61 62 63 createEffect(() => {64 if (phase() !== "pair") return;65 if (receiverInvitation() || receiverError()) return;66 void (async () => {67 const response = (await sendMessage({68 type: "START_ADOPTION_RECEIVER",69 })) as { ok: boolean; data?: { invitation?: string }; error?: string };70 if (response.ok && response.data?.invitation) {71 setReceiverInvitation(response.data.invitation);72 } else {73 setReceiverError(response.error ?? "adoption_receiver_failed");74 }75 })();76 });7778 createMemo(() => {79 const value = password();80 if (!value) {81 setStrengthScore(null);82 return;83 }84 getZxcvbn().then((zxcvbn) => {85 if (password() === value) setStrengthScore(zxcvbn(value).score);86 }).catch(() => {87 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);88 });89 });9091 const canContinue = createMemo(() =>92 password().length >= MIN_LENGTH &&93 confirmPassword().length >= MIN_LENGTH &&94 password() === confirmPassword() &&95 strengthScore() !== null &&96 strengthScore()! >= MIN_ZXCVBN_SCORE97 );9899 const createBlocker = createMemo(() => {100 if (!password()) return null;101 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;102 if (!confirmPassword()) return "confirm your password";103 if (password() !== confirmPassword()) return "passwords don't match";104 if (strengthScore() === null) return "checking strength...";105 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";106 return null;107 });108109 return (110 <section class="peach-screen peach-theme peach-adopt-vault">111 <RuneBackground opacity={0.07} />112 <div class="peach-adopt-card">113 <Switch>114 <Match when={phase() === "password"}>115 <h1>Set a password for this device.</h1>116 <p>This protects your vault on this device only. It can be different from your other devices.</p>117 <label class="peach-label" for="adopt-password">password</label>118 <input119 id="adopt-password"120 class="peach-underline-input peach-adopt-input"121 type="password"122 autocomplete="new-password"123 value={password()}124 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}125 />126 <label class="peach-label" for="adopt-confirm-password">confirm password</label>127 <input128 id="adopt-confirm-password"129 class="peach-underline-input peach-adopt-input"130 type="password"131 autocomplete="new-password"132 value={confirmPassword()}133 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}134 />135 <Show when={createBlocker()}>136 <p class="peach-hint">{createBlocker()}</p>137 </Show>138 <div class="peach-adopt-actions">139 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>140 Continue141 </button>142 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>143 </div>144 </Match>145146 <Match when={phase() === "pair"}>147 <h1>Pair with your other device.</h1>148 {149150151}152 <p>153 Scan this code on the device that holds your vault. That154 device will send the vault to this extension once the codes155 match.156 </p>157 <p class="peach-hint">{RELAY_DISCLOSURE}</p>158 <Show159 when={receiverInvitation()}160 fallback={161 <Show162 when={receiverError()}163 fallback={<p class="peach-hint">Preparing secure pairing room…</p>}164 >165 <p class="peach-hint peach-hint-error">{receiverError()}</p>166 </Show>167 }168 >169 <PairingQrCode value={receiverInvitation()} size={200} />170 </Show>171 <div class="peach-adopt-actions">172 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>173 </div>174 </Match>175 </Switch>176 </div>177 </section>178 );179}