Skip to content

Commit 3971ab8

Browse files
sea-snakeCopilot
andauthored
Recovery phrase unverified status based on last_authentication. (#3519)
Recovery phrase unverified status based on `last_authentication`. # Changes - Renamed `Verify` component to `VerifySelecting`. - Implemented `RecoveryPhraseInput` component. - Use above component to implement `VerifyTyping`. - Update `RecoveryPhraseWizard` to use either `VerifySelecting` or `VerifyTyping` depending on the availability of the valid recovery phrase in memory (e.g. user signed out and in -> not available). - Update `/manage/recovery` page to use `last_authentication` to decide if a recovery phrase is unverified (null = not used yet). # Tests No tests have been updated in this PR, existing e2e tests should pass in the CI/CD pipeline. # Notes - The `RecoveryPhraseInput` component will be used on the `use recovery phrase screen` in a later PR. - Additional e2e tests that cover `VerifyTyping` will be added in a later PR. <!-- SCREENSHOTS REPORT START --> <!-- SCREENSHOTS REPORT STOP --> --------- Co-authored-by: Copilot <[email protected]>
1 parent 3611a9e commit 3971ab8

File tree

6 files changed

+412
-56
lines changed

6 files changed

+412
-56
lines changed
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<script lang="ts">
2+
import Tooltip from "$lib/components/ui/Tooltip.svelte";
3+
import { InfoIcon } from "@lucide/svelte";
4+
import { t } from "$lib/stores/locale.store";
5+
import { onMount } from "svelte";
6+
7+
interface Props {
8+
value: string[];
9+
showValues?: boolean;
10+
disabled?: boolean;
11+
}
12+
13+
let {
14+
value = $bindable(),
15+
showValues = false,
16+
disabled = false,
17+
}: Props = $props();
18+
19+
let words = $derived(Array.from(value));
20+
let wrapperRef = $state<HTMLDivElement>();
21+
let dictionary = $state<string[]>();
22+
23+
const isCompleteAndCorrect = $derived(
24+
words.every(
25+
(word) =>
26+
word.length > 0 &&
27+
dictionary !== undefined &&
28+
dictionary.includes(word),
29+
),
30+
);
31+
const hasChanges = $derived(words.join(" ") === value.join(" "));
32+
const inputPattern = $derived(dictionary?.join("|"));
33+
const firstEmptyInputIndex = $derived(
34+
words.findIndex((word) => word.length === 0),
35+
);
36+
37+
/** Focus on input (if it can be found for given index) */
38+
const focusInput = (index: number) =>
39+
wrapperRef?.querySelectorAll("input")[index]?.focus();
40+
/** Change value into lower case and filter out disallowed characters */
41+
const maskInput = (value: string) =>
42+
value.toLowerCase().replace(/[^a-z]/g, "");
43+
/** Switch focus between inputs when user presses certain keys */
44+
const handleKeyDown = (event: KeyboardEvent, index: number) => {
45+
if (event.code === "Backspace" && words[index].length === 0) {
46+
focusInput(index - 1);
47+
event.preventDefault();
48+
}
49+
if (event.code === "Space" || event.code === "Enter") {
50+
focusInput(index + 1);
51+
event.preventDefault();
52+
}
53+
};
54+
/**
55+
* Handle pasting clipboard content, split it into multiple words based on
56+
* whitespace and fills inputs with these words starting from current input.
57+
*/
58+
const handlePaste = (event: ClipboardEvent, index: number) => {
59+
const clipboard = (event.clipboardData?.getData("text/plain") ?? "")
60+
.trim()
61+
.split(/\s/)
62+
.map((word) => word.toLowerCase().replace(/[^a-z]/g, ""));
63+
if (clipboard.length > 0) {
64+
clipboard.forEach((word, i) => {
65+
if (index + i >= word.length) {
66+
return;
67+
}
68+
words[index + i] = word;
69+
});
70+
words = [...words];
71+
event.preventDefault();
72+
focusInput(Math.min(index + clipboard.length - 1, words.length - 1));
73+
}
74+
};
75+
/**
76+
* Update binding when recovery phrase is filled in completely and correctly,
77+
* this is with a timeout so that the user can still type "act" -> "actor".
78+
*
79+
* @return function to cancel the update within the timeout
80+
*/
81+
const updateBinding = () => {
82+
if (!isCompleteAndCorrect || hasChanges) {
83+
return;
84+
}
85+
86+
const timeout = setTimeout(() => {
87+
value = words;
88+
}, 1000);
89+
return () => {
90+
clearTimeout(timeout);
91+
};
92+
};
93+
94+
onMount(() => {
95+
// Lazy load dictionary so this component doesn't include it in the bundle eagerly
96+
import("bip39").then((bip39) => (dictionary = bip39.wordlists.english));
97+
focusInput(firstEmptyInputIndex);
98+
});
99+
100+
$effect(updateBinding);
101+
</script>
102+
103+
<div bind:this={wrapperRef} class="grid grid-cols-3 gap-3">
104+
{#each words as word, index}
105+
<label class="relative">
106+
<input
107+
inputmode="text"
108+
autocorrect="off"
109+
autocomplete="off"
110+
autocapitalize="off"
111+
spellcheck="false"
112+
bind:value={
113+
() => word,
114+
(v) => {
115+
words[index] = maskInput(v);
116+
words = [...words];
117+
}
118+
}
119+
onkeydown={(event) => handleKeyDown(event, index)}
120+
onpaste={(event) => handlePaste(event, index)}
121+
pattern={inputPattern}
122+
{disabled}
123+
class={[
124+
"peer h-7 w-full ps-8 pe-2",
125+
"text-text-primary bg-transparent text-base ring-0 outline-none",
126+
"border-border-primary rounded-full",
127+
"focus:not-disabled:border-fg-primary",
128+
"not-focus:user-invalid:!border-border-error not-focus:user-invalid:!bg-bg-error-primary/30 not-focus:user-invalid:!pe-7",
129+
!showValues &&
130+
"not-focus:valid:!text-transparent disabled:!text-transparent",
131+
"disabled:!text-text-disabled disabled:!bg-bg-disabled disabled:!border-border-disabled_subtle",
132+
word.length > 7 && "tracking-tight",
133+
]}
134+
data-lpignore="true"
135+
data-1p-ignore="true"
136+
data-bwignore="true"
137+
data-form-type="other"
138+
/>
139+
<span
140+
class={[
141+
"pointer-events-none absolute inset-y-0 start-0.25 flex h-7 items-center",
142+
"border-border-primary text-text-secondary border-r-1",
143+
"peer-focus:border-fg-primary peer-focus:!text-text-primary",
144+
"peer-not-focus:peer-user-invalid:!border-border-error peer-not-focus:peer-user-invalid:!text-text-error-primary",
145+
"peer-disabled:!border-border-disabled_subtle",
146+
]}
147+
aria-hidden="true"
148+
>
149+
<span class="ms-1.5 me-1 text-xs font-semibold tabular-nums">
150+
{`${index + 1}`.padStart(2, "0")}
151+
</span>
152+
</span>
153+
<span
154+
class={[
155+
"pointer-events-none absolute inset-y-0.25 start-8 hidden",
156+
"text-text-primary bg-transparent text-base tracking-tight",
157+
"peer-disabled:!text-text-disabled",
158+
word.length > 0 &&
159+
!showValues &&
160+
"peer-not-focus:peer-valid:!block peer-disabled:!block",
161+
]}
162+
aria-hidden="true"
163+
>
164+
•••••••
165+
</span>
166+
<span
167+
class="peer-valid:hidden peer-focus:hidden peer-disabled:hidden"
168+
aria-hidden="true"
169+
>
170+
<Tooltip
171+
label={$t`Incorrect spelling`}
172+
direction="up"
173+
align="end"
174+
distance="0.5rem"
175+
>
176+
<span
177+
class={[
178+
"absolute inset-y-0 end-0",
179+
"flex aspect-square h-full items-center justify-center rounded-full",
180+
]}
181+
>
182+
<InfoIcon class="text-text-error-primary size-4" />
183+
</span>
184+
</Tooltip>
185+
</span>
186+
</label>
187+
{/each}
188+
</div>
Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,81 @@
11
<script lang="ts">
22
import Acknowledge from "$lib/components/wizards/createRecoveryPhrase/views/Acknowledge.svelte";
33
import Write from "$lib/components/wizards/createRecoveryPhrase/views/Write.svelte";
4-
import Verify from "$lib/components/wizards/createRecoveryPhrase/views/Verify.svelte";
4+
import VerifySelecting from "$lib/components/wizards/createRecoveryPhrase/views/VerifySelecting.svelte";
5+
import VerifyTyping from "$lib/components/wizards/createRecoveryPhrase/views/VerifyTyping.svelte";
56
import { generateMnemonic } from "$lib/utils/recoveryPhrase";
67
import Reset from "$lib/components/wizards/createRecoveryPhrase/views/Reset.svelte";
7-
import { waitFor } from "$lib/utils/utils";
88
import Retry from "$lib/components/wizards/createRecoveryPhrase/views/Retry.svelte";
99
1010
interface Props {
11+
action?: "create" | "verify";
1112
onCreate: (recoveryPhrase: string[]) => Promise<void>;
12-
onVerified: () => void;
13+
onVerify: (recoveryPhrase: string[]) => Promise<boolean>;
1314
onCancel: () => void;
15+
onError: (error: unknown) => void;
1416
unverifiedRecoveryPhrase?: string[];
1517
hasExistingRecoveryPhrase?: boolean;
1618
}
1719
1820
const {
21+
action = "create",
1922
onCreate,
20-
onVerified,
23+
onVerify,
2124
onCancel,
25+
onError,
2226
unverifiedRecoveryPhrase,
2327
hasExistingRecoveryPhrase,
2428
}: Props = $props();
2529
26-
let recoveryPhrase = $state(unverifiedRecoveryPhrase);
27-
let isWritten = $state(unverifiedRecoveryPhrase !== undefined);
30+
let recoveryPhrase = $state<string[] | undefined>(
31+
action === "verify" ? unverifiedRecoveryPhrase : undefined,
32+
);
33+
let isWritten = $state(action === "verify");
2834
let isIncorrect = $state(false);
35+
let incorrectRecoveryPhrase = $state<string[]>();
2936
3037
const createRecoveryPhrase = async () => {
31-
const generated = generateMnemonic();
32-
await onCreate(generated);
33-
recoveryPhrase = generated;
38+
try {
39+
const generated = generateMnemonic();
40+
await onCreate(generated);
41+
recoveryPhrase = generated;
42+
} catch (error) {
43+
onError(error);
44+
}
3445
};
3546
const verifyRecoveryPhrase = async (entered: string[]) => {
36-
// Artificial delay to improve UX, instant feedback would be strange
37-
// after the user spent some time on selecting words one after another.
38-
await waitFor(2000);
39-
if (recoveryPhrase?.join(" ") !== entered.join(" ")) {
40-
isIncorrect = true;
41-
return;
47+
try {
48+
isIncorrect = !(await onVerify(entered));
49+
incorrectRecoveryPhrase = isIncorrect ? entered : undefined;
50+
} catch (error) {
51+
onError(error);
4252
}
43-
onVerified();
4453
};
4554
const retryVerification = () => {
4655
isWritten = false;
4756
isIncorrect = false;
4857
};
4958
</script>
5059

51-
{#if recoveryPhrase === undefined}
60+
{#if action === "create" && recoveryPhrase === undefined}
5261
{#if hasExistingRecoveryPhrase}
5362
<Reset onReset={createRecoveryPhrase} {onCancel} />
5463
{:else}
5564
<Acknowledge onAcknowledged={createRecoveryPhrase} />
5665
{/if}
57-
{:else if !isWritten}
66+
{:else if !isWritten && recoveryPhrase !== undefined}
5867
<Write {recoveryPhrase} onWritten={() => (isWritten = true)} />
5968
{:else if isIncorrect}
60-
<Retry onRetry={retryVerification} {onCancel} />
69+
<Retry
70+
onRetry={retryVerification}
71+
{onCancel}
72+
verificationMethod={recoveryPhrase !== undefined ? "selecting" : "typing"}
73+
/>
74+
{:else if recoveryPhrase !== undefined}
75+
<VerifySelecting {recoveryPhrase} onCompleted={verifyRecoveryPhrase} />
6176
{:else}
62-
<Verify {recoveryPhrase} onCompleted={verifyRecoveryPhrase} />
77+
<VerifyTyping
78+
onCompleted={verifyRecoveryPhrase}
79+
recoveryPhrase={incorrectRecoveryPhrase}
80+
/>
6381
{/if}

src/frontend/src/lib/components/wizards/createRecoveryPhrase/views/Retry.svelte

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
interface Props {
99
onRetry: () => void;
1010
onCancel: () => void;
11+
verificationMethod: "selecting" | "typing";
1112
}
1213
13-
const { onRetry, onCancel }: Props = $props();
14+
const { onRetry, onCancel, verificationMethod }: Props = $props();
1415
</script>
1516

1617
<FeaturedIcon variant="error" size="lg" class="mb-4">
@@ -20,7 +21,11 @@
2021
{$t`Something is wrong!`}
2122
</h2>
2223
<p class="text-text-tertiary mb-8 text-base font-medium">
23-
<Trans>Incorrect word order. Review and try again.</Trans>
24+
{#if verificationMethod === "selecting"}
25+
<Trans>Incorrect word order. Review and try again.</Trans>
26+
{:else}
27+
<Trans>Incorrect recovery phrase. Please try again.</Trans>
28+
{/if}
2429
</p>
2530
<Button onclick={onRetry} size="lg" class="mb-1.5">
2631
{$t`Retry`}

src/frontend/src/lib/components/wizards/createRecoveryPhrase/views/Verify.svelte renamed to src/frontend/src/lib/components/wizards/createRecoveryPhrase/views/VerifySelecting.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
{#if isCheckingOrder}
6565
<Trans>This may take a few seconds</Trans>
6666
{:else}
67-
<Trans>Select each word in the correct order</Trans>
67+
<Trans>Select each word in the correct order:</Trans>
6868
{/if}
6969
</p>
7070
<ul class={["mb-8 grid grid-cols-3 gap-3"]}>

0 commit comments

Comments
 (0)