Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions src/frontend/src/lib/components/views/RecoveryPhraseInput.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<script lang="ts">
import Tooltip from "$lib/components/ui/Tooltip.svelte";
import { InfoIcon } from "@lucide/svelte";
import { t } from "$lib/stores/locale.store";
import { onMount } from "svelte";

interface Props {
value: string[];
showValues?: boolean;
disabled?: boolean;
}

let {
value = $bindable(),
showValues = false,
disabled = false,
}: Props = $props();

let words = $derived(Array.from(value));
let wrapperRef = $state<HTMLDivElement>();
let dictionary = $state<string[]>();

const isCompleteAndCorrect = $derived(
words.every(
(word) =>
word.length > 0 &&
dictionary !== undefined &&
dictionary.includes(word),
),
);
const hasChanges = $derived(words.join(" ") === value.join(" "));
const inputPattern = $derived(dictionary?.join("|"));
const firstEmptyInputIndex = $derived(
words.findIndex((word) => word.length === 0),
);

/** Focus on input (if it can be found for given index) */
const focusInput = (index: number) =>
wrapperRef?.querySelectorAll("input")[index]?.focus();
/** Change value into lower case and filter out disallowed characters */
const maskInput = (value: string) =>
value.toLowerCase().replace(/[^a-z]/g, "");
/** Switch focus between inputs when user presses certain keys */
const handleKeyDown = (event: KeyboardEvent, index: number) => {
if (event.code === "Backspace" && words[index].length === 0) {
focusInput(index - 1);
event.preventDefault();
}
if (event.code === "Space" || event.code === "Enter") {
focusInput(index + 1);
event.preventDefault();
}
};
/**
* Handle pasting clipboard content, split it into multiple words based on
* whitespace and fills inputs with these words starting from current input.
*/
const handlePaste = (event: ClipboardEvent, index: number) => {
const clipboard = (event.clipboardData?.getData("text/plain") ?? "")
.trim()
.split(/\s/)
.map((word) => word.toLowerCase().replace(/[^a-z]/g, ""));
if (clipboard.length > 0) {
clipboard.forEach((word, i) => {
if (index + i >= word.length) {
return;
}
words[index + i] = word;
});
words = [...words];
event.preventDefault();
focusInput(Math.min(index + clipboard.length - 1, words.length - 1));
}
};
/**
* Update binding when recovery phrase is filled in completely and correctly,
* this is with a timeout so that the user can still type "act" -> "actor".
*
* @return function to cancel the update within the timeout
*/
const updateBinding = () => {
if (!isCompleteAndCorrect || hasChanges) {
return;
}

const timeout = setTimeout(() => {
value = words;
}, 1000);
return () => {
clearTimeout(timeout);
};
};

onMount(() => {
// Lazy load dictionary so this component doesn't include it in the bundle eagerly
import("bip39").then((bip39) => (dictionary = bip39.wordlists.english));
focusInput(firstEmptyInputIndex);
});

$effect(updateBinding);
</script>

<div bind:this={wrapperRef} class="grid grid-cols-3 gap-3">
{#each words as word, index}
<label class="relative">
<input
inputmode="text"
autocorrect="off"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
bind:value={
() => word,
(v) => {
words[index] = maskInput(v);
words = [...words];
}
}
onkeydown={(event) => handleKeyDown(event, index)}
onpaste={(event) => handlePaste(event, index)}
pattern={inputPattern}
{disabled}
class={[
"peer h-7 w-full ps-8 pe-2",
"text-text-primary bg-transparent text-base ring-0 outline-none",
"border-border-primary rounded-full",
"focus:not-disabled:border-fg-primary",
"not-focus:user-invalid:!border-border-error not-focus:user-invalid:!bg-bg-error-primary/30 not-focus:user-invalid:!pe-7",
!showValues &&
"not-focus:valid:!text-transparent disabled:!text-transparent",
"disabled:!text-text-disabled disabled:!bg-bg-disabled disabled:!border-border-disabled_subtle",
word.length > 7 && "tracking-tight",
]}
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
/>
<span
class={[
"pointer-events-none absolute inset-y-0 start-0.25 flex h-7 items-center",
"border-border-primary text-text-secondary border-r-1",
"peer-focus:border-fg-primary peer-focus:!text-text-primary",
"peer-not-focus:peer-user-invalid:!border-border-error peer-not-focus:peer-user-invalid:!text-text-error-primary",
"peer-disabled:!border-border-disabled_subtle",
]}
aria-hidden="true"
>
<span class="ms-1.5 me-1 text-xs font-semibold tabular-nums">
{`${index + 1}`.padStart(2, "0")}
</span>
</span>
<span
class={[
"pointer-events-none absolute inset-y-0.25 start-8 hidden",
"text-text-primary bg-transparent text-base tracking-tight",
"peer-disabled:!text-text-disabled",
word.length > 0 &&
!showValues &&
"peer-not-focus:peer-valid:!block peer-disabled:!block",
]}
aria-hidden="true"
>
•••••••
</span>
<span
class="peer-valid:hidden peer-focus:hidden peer-disabled:hidden"
aria-hidden="true"
>
<Tooltip
label={$t`Incorrect spelling`}
direction="up"
align="end"
distance="0.5rem"
>
<span
class={[
"absolute inset-y-0 end-0",
"flex aspect-square h-full items-center justify-center rounded-full",
]}
>
<InfoIcon class="text-text-error-primary size-4" />
</span>
</Tooltip>
</span>
</label>
{/each}
</div>
Original file line number Diff line number Diff line change
@@ -1,63 +1,81 @@
<script lang="ts">
import Acknowledge from "$lib/components/wizards/createRecoveryPhrase/views/Acknowledge.svelte";
import Write from "$lib/components/wizards/createRecoveryPhrase/views/Write.svelte";
import Verify from "$lib/components/wizards/createRecoveryPhrase/views/Verify.svelte";
import VerifySelecting from "$lib/components/wizards/createRecoveryPhrase/views/VerifySelecting.svelte";
import VerifyTyping from "$lib/components/wizards/createRecoveryPhrase/views/VerifyTyping.svelte";
import { generateMnemonic } from "$lib/utils/recoveryPhrase";
import Reset from "$lib/components/wizards/createRecoveryPhrase/views/Reset.svelte";
import { waitFor } from "$lib/utils/utils";
import Retry from "$lib/components/wizards/createRecoveryPhrase/views/Retry.svelte";
interface Props {
action?: "create" | "verify";
onCreate: (recoveryPhrase: string[]) => Promise<void>;
onVerified: () => void;
onVerify: (recoveryPhrase: string[]) => Promise<boolean>;
onCancel: () => void;
onError: (error: unknown) => void;
unverifiedRecoveryPhrase?: string[];
hasExistingRecoveryPhrase?: boolean;
}
const {
action = "create",
onCreate,
onVerified,
onVerify,
onCancel,
onError,
unverifiedRecoveryPhrase,
hasExistingRecoveryPhrase,
}: Props = $props();
let recoveryPhrase = $state(unverifiedRecoveryPhrase);
let isWritten = $state(unverifiedRecoveryPhrase !== undefined);
let recoveryPhrase = $state<string[] | undefined>(
action === "verify" ? unverifiedRecoveryPhrase : undefined,
);
let isWritten = $state(action === "verify");
let isIncorrect = $state(false);
let incorrectRecoveryPhrase = $state<string[]>();
const createRecoveryPhrase = async () => {
const generated = generateMnemonic();
await onCreate(generated);
recoveryPhrase = generated;
try {
const generated = generateMnemonic();
await onCreate(generated);
recoveryPhrase = generated;
} catch (error) {
onError(error);
}
};
const verifyRecoveryPhrase = async (entered: string[]) => {
// Artificial delay to improve UX, instant feedback would be strange
// after the user spent some time on selecting words one after another.
await waitFor(2000);
if (recoveryPhrase?.join(" ") !== entered.join(" ")) {
isIncorrect = true;
return;
try {
isIncorrect = !(await onVerify(entered));
incorrectRecoveryPhrase = isIncorrect ? entered : undefined;
} catch (error) {
onError(error);
}
onVerified();
};
const retryVerification = () => {
isWritten = false;
isIncorrect = false;
};
</script>

{#if recoveryPhrase === undefined}
{#if action === "create" && recoveryPhrase === undefined}
{#if hasExistingRecoveryPhrase}
<Reset onReset={createRecoveryPhrase} {onCancel} />
{:else}
<Acknowledge onAcknowledged={createRecoveryPhrase} />
{/if}
{:else if !isWritten}
{:else if !isWritten && recoveryPhrase !== undefined}
<Write {recoveryPhrase} onWritten={() => (isWritten = true)} />
{:else if isIncorrect}
<Retry onRetry={retryVerification} {onCancel} />
<Retry
onRetry={retryVerification}
{onCancel}
verificationMethod={recoveryPhrase !== undefined ? "selecting" : "typing"}
/>
{:else if recoveryPhrase !== undefined}
<VerifySelecting {recoveryPhrase} onCompleted={verifyRecoveryPhrase} />
{:else}
<Verify {recoveryPhrase} onCompleted={verifyRecoveryPhrase} />
<VerifyTyping
onCompleted={verifyRecoveryPhrase}
recoveryPhrase={incorrectRecoveryPhrase}
/>
{/if}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
interface Props {
onRetry: () => void;
onCancel: () => void;
verificationMethod: "selecting" | "typing";
}

const { onRetry, onCancel }: Props = $props();
const { onRetry, onCancel, verificationMethod }: Props = $props();
</script>

<FeaturedIcon variant="error" size="lg" class="mb-4">
Expand All @@ -20,7 +21,11 @@
{$t`Something is wrong!`}
</h2>
<p class="text-text-tertiary mb-8 text-base font-medium">
<Trans>Incorrect word order. Review and try again.</Trans>
{#if verificationMethod === "selecting"}
<Trans>Incorrect word order. Review and try again.</Trans>
{:else}
<Trans>Incorrect recovery phrase. Please try again.</Trans>
{/if}
</p>
<Button onclick={onRetry} size="lg" class="mb-1.5">
{$t`Retry`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
{#if isCheckingOrder}
<Trans>This may take a few seconds</Trans>
{:else}
<Trans>Select each word in the correct order</Trans>
<Trans>Select each word in the correct order:</Trans>
{/if}
</p>
<ul class={["mb-8 grid grid-cols-3 gap-3"]}>
Expand Down
Loading
Loading