|
1 | 1 | <script lang="ts"> |
2 | | -import { goto } from "$app/navigation"; |
3 | | -import { keyboardInset } from "$lib/actions/keyboardInset"; |
4 | | -import type { GlobalState } from "$lib/global"; |
5 | | -import { LoadingSheet, PinDots } from "$lib/ui"; |
6 | | -import * as Button from "$lib/ui/Button"; |
7 | | -import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; |
8 | | -import { |
9 | | - type AuthOptions, |
10 | | - authenticate, |
11 | | - checkStatus, |
12 | | -} from "@tauri-apps/plugin-biometric"; |
13 | | -import { getContext, onMount } from "svelte"; |
14 | | -import StepHeader from "../onboarding/steps/StepHeader.svelte"; |
15 | | -
|
16 | | -// Splash sets this when it has already tried biometric over its own screen. |
17 | | -// /login then skips re-prompting and just shows the PIN UI. |
18 | | -const BIOMETRIC_ATTEMPTED_KEY = "biometricAttemptedOnSplash"; |
19 | | -
|
20 | | -let pin = $state(""); |
21 | | -let isError = $state(false); |
22 | | -let isPostAuthLoading = $state(false); |
23 | | -let hasPendingDeepLink = $state(false); |
24 | | -let pinInput = $state<HTMLInputElement | undefined>(undefined); |
25 | | -
|
26 | | -// Refocus the hidden PIN input on background taps so the user doesn't have |
27 | | -// to aim for the dots themselves to summon the keyboard. We skip taps that |
28 | | -// land on interactive elements so their own click handlers (Clear PIN, back |
29 | | -// chevron, etc.) still behave normally. |
30 | | -function handleBackgroundClick(e: MouseEvent) { |
31 | | - const target = e.target as HTMLElement | null; |
32 | | - if (target?.closest('button, a, [role="button"]')) return; |
33 | | - pinInput?.focus({ preventScroll: true }); |
34 | | -} |
35 | | -
|
36 | | -const getGlobalState = getContext<() => GlobalState | undefined>("globalState"); |
37 | | -let globalState: GlobalState | undefined = $state(undefined); |
38 | | -
|
39 | | -const authOpts: AuthOptions = { |
40 | | - allowDeviceCredential: false, |
41 | | - cancelTitle: "Cancel", |
42 | | - // iOS |
43 | | - fallbackTitle: "Please enter your PIN", |
44 | | - // Android |
45 | | - title: "Login", |
46 | | - subtitle: "Please authenticate to continue", |
47 | | - confirmationRequired: true, |
48 | | -}; |
49 | | -
|
50 | | -async function clearPin() { |
51 | | - if (isPostAuthLoading) return; |
52 | | - pin = ""; |
53 | | - isError = false; |
54 | | -} |
55 | | -
|
56 | | -async function verifyAndAdvance(currentPin: string) { |
57 | | - if (isPostAuthLoading) return; |
58 | | - if (!globalState) return; |
59 | | - if (currentPin.length !== 4) return; |
60 | | -
|
61 | | - isError = false; |
62 | | - isPostAuthLoading = true; |
63 | | -
|
64 | | - const ok = await globalState.securityController.verifyPin(currentPin); |
65 | | - if (!ok) { |
66 | | - isError = true; |
67 | | - pin = ""; |
68 | | - isPostAuthLoading = false; |
69 | | - return; |
| 2 | + import { goto } from "$app/navigation"; |
| 3 | + import { keyboardInset } from "$lib/actions/keyboardInset"; |
| 4 | + import type { GlobalState } from "$lib/global"; |
| 5 | + import { LoadingSheet, PinDots } from "$lib/ui"; |
| 6 | + import * as Button from "$lib/ui/Button"; |
| 7 | + import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; |
| 8 | + import { |
| 9 | + type AuthOptions, |
| 10 | + authenticate, |
| 11 | + checkStatus, |
| 12 | + } from "@tauri-apps/plugin-biometric"; |
| 13 | + import { getContext, onMount } from "svelte"; |
| 14 | + import StepHeader from "../onboarding/steps/StepHeader.svelte"; |
| 15 | +
|
| 16 | + // Splash sets this when it has already tried biometric over its own screen. |
| 17 | + // /login then skips re-prompting and just shows the PIN UI. |
| 18 | + const BIOMETRIC_ATTEMPTED_KEY = "biometricAttemptedOnSplash"; |
| 19 | +
|
| 20 | + let pin = $state(""); |
| 21 | + let isError = $state(false); |
| 22 | + let isPostAuthLoading = $state(false); |
| 23 | + let hasPendingDeepLink = $state(false); |
| 24 | + let pinInput = $state<HTMLInputElement | undefined>(undefined); |
| 25 | +
|
| 26 | + // Refocus the hidden PIN input on background taps so the user doesn't have |
| 27 | + // to aim for the dots themselves to summon the keyboard. We skip taps that |
| 28 | + // land on interactive elements so their own click handlers (Clear PIN, back |
| 29 | + // chevron, etc.) still behave normally. |
| 30 | + function handleBackgroundClick(e: MouseEvent) { |
| 31 | + const target = e.target as HTMLElement | null; |
| 32 | + if (target?.closest('button, a, [role="button"]')) return; |
| 33 | + pinInput?.focus({ preventScroll: true }); |
70 | 34 | } |
71 | 35 |
|
72 | | - await continueAfterSuccessfulAuth(globalState); |
73 | | -} |
74 | | -
|
75 | | -$effect(() => { |
76 | | - if (pin.length === 4) verifyAndAdvance(pin); |
77 | | -}); |
78 | | -
|
79 | | -onMount(async () => { |
80 | | - // Root +layout creates globalState in its own onMount (which runs after |
81 | | - // children). Poll until it's available — same pattern as (app)/+layout. |
82 | | - let gs = getGlobalState(); |
83 | | - let retries = 0; |
84 | | - while (!gs && retries < 50) { |
85 | | - await new Promise((r) => setTimeout(r, 100)); |
86 | | - gs = getGlobalState(); |
87 | | - retries++; |
88 | | - } |
89 | | - if (!gs) { |
90 | | - console.error("Global state never became available"); |
91 | | - await goto("/"); |
92 | | - return; |
93 | | - } |
94 | | - globalState = gs; |
95 | | -
|
96 | | - const pendingDeepLink = sessionStorage.getItem("pendingDeepLink"); |
97 | | - hasPendingDeepLink = !!pendingDeepLink; |
98 | | -
|
99 | | - // If the splash already prompted biometric over its own screen, skip the |
100 | | - // retry here and let the user enter their PIN. The flag survives the |
101 | | - // route transition but is single-use. |
102 | | - const biometricHandledBySplash = |
103 | | - sessionStorage.getItem(BIOMETRIC_ATTEMPTED_KEY) === "true"; |
104 | | - if (biometricHandledBySplash) { |
105 | | - sessionStorage.removeItem(BIOMETRIC_ATTEMPTED_KEY); |
106 | | - return; |
| 36 | + const getGlobalState = |
| 37 | + getContext<() => GlobalState | undefined>("globalState"); |
| 38 | + let globalState: GlobalState | undefined = $state(undefined); |
| 39 | +
|
| 40 | + const authOpts: AuthOptions = { |
| 41 | + allowDeviceCredential: false, |
| 42 | + cancelTitle: "Cancel", |
| 43 | + // iOS |
| 44 | + fallbackTitle: "Please enter your PIN", |
| 45 | + // Android |
| 46 | + title: "Login", |
| 47 | + subtitle: "Please authenticate to continue", |
| 48 | + confirmationRequired: true, |
| 49 | + }; |
| 50 | +
|
| 51 | + async function clearPin() { |
| 52 | + if (isPostAuthLoading) return; |
| 53 | + pin = ""; |
| 54 | + isError = false; |
107 | 55 | } |
108 | 56 |
|
109 | | - // Try biometric first if available. |
110 | | - if ( |
111 | | - (await gs.securityController.biometricSupport) && |
112 | | - (await checkStatus()).isAvailable |
113 | | - ) { |
| 57 | + async function verifyAndAdvance(currentPin: string) { |
| 58 | + if (isPostAuthLoading) return; |
| 59 | + if (!globalState) return; |
| 60 | + if (currentPin.length !== 4) return; |
| 61 | +
|
| 62 | + isError = false; |
| 63 | + isPostAuthLoading = true; |
| 64 | +
|
114 | 65 | try { |
115 | | - await authenticate( |
116 | | - "You must authenticate with PIN first", |
117 | | - authOpts, |
118 | | - ); |
119 | | - isPostAuthLoading = true; |
120 | | - await continueAfterSuccessfulAuth(gs); |
| 66 | + const ok = await globalState.securityController.verifyPin(currentPin); |
| 67 | + if (!ok) { |
| 68 | + isError = true; |
| 69 | + pin = ""; |
| 70 | + return; |
| 71 | + } |
| 72 | +
|
| 73 | + await continueAfterSuccessfulAuth(globalState); |
121 | 74 | } catch (e) { |
122 | | - console.error("Biometric authentication failed", e); |
| 75 | + console.error("PIN verification failed", e); |
| 76 | + isError = true; |
| 77 | + pin = ""; |
| 78 | + } finally { |
123 | 79 | isPostAuthLoading = false; |
124 | 80 | } |
125 | 81 | } |
126 | | -}); |
| 82 | +
|
| 83 | + $effect(() => { |
| 84 | + if (pin.length === 4) verifyAndAdvance(pin); |
| 85 | + }); |
| 86 | +
|
| 87 | + onMount(async () => { |
| 88 | + // Root +layout creates globalState in its own onMount (which runs after |
| 89 | + // children). Poll until it's available — same pattern as (app)/+layout. |
| 90 | + let gs = getGlobalState(); |
| 91 | + let retries = 0; |
| 92 | + while (!gs && retries < 50) { |
| 93 | + await new Promise((r) => setTimeout(r, 100)); |
| 94 | + gs = getGlobalState(); |
| 95 | + retries++; |
| 96 | + } |
| 97 | + if (!gs) { |
| 98 | + console.error("Global state never became available"); |
| 99 | + await goto("/"); |
| 100 | + return; |
| 101 | + } |
| 102 | + globalState = gs; |
| 103 | +
|
| 104 | + const pendingDeepLink = sessionStorage.getItem("pendingDeepLink"); |
| 105 | + hasPendingDeepLink = !!pendingDeepLink; |
| 106 | +
|
| 107 | + // If the splash already prompted biometric over its own screen, skip the |
| 108 | + // retry here and let the user enter their PIN. The flag survives the |
| 109 | + // route transition but is single-use. |
| 110 | + const biometricHandledBySplash = |
| 111 | + sessionStorage.getItem(BIOMETRIC_ATTEMPTED_KEY) === "true"; |
| 112 | + if (biometricHandledBySplash) { |
| 113 | + sessionStorage.removeItem(BIOMETRIC_ATTEMPTED_KEY); |
| 114 | + return; |
| 115 | + } |
| 116 | +
|
| 117 | + // Try biometric first if available. |
| 118 | + if ( |
| 119 | + (await gs.securityController.biometricSupport) && |
| 120 | + (await checkStatus()).isAvailable |
| 121 | + ) { |
| 122 | + try { |
| 123 | + await authenticate( |
| 124 | + "You must authenticate with PIN first", |
| 125 | + authOpts, |
| 126 | + ); |
| 127 | + isPostAuthLoading = true; |
| 128 | + await continueAfterSuccessfulAuth(gs); |
| 129 | + } catch (e) { |
| 130 | + console.error("Biometric authentication failed", e); |
| 131 | + isPostAuthLoading = false; |
| 132 | + } |
| 133 | + } |
| 134 | + }); |
127 | 135 | </script> |
128 | 136 |
|
129 | 137 | <!-- The PIN input is the only meaningful interaction here; keyboard users |
@@ -154,9 +162,16 @@ onMount(async () => { |
154 | 162 | <PinDots bind:pin bind:inputEl={pinInput} /> |
155 | 163 |
|
156 | 164 | {#if isError} |
157 | | - <p class="text-danger text-sm font-medium" role="alert"> |
158 | | - Your PIN does not match, try again. |
159 | | - </p> |
| 165 | + <article class="flex flex-col items-center justify-center gap-2"> |
| 166 | + <p class="text-danger text-sm font-medium" role="alert"> |
| 167 | + Your PIN does not match, try again. |
| 168 | + </p> |
| 169 | + <p class="text-black-700 opacity-50 text-sm font-medium"> |
| 170 | + Forgot your pin? <a href="/recover" |
| 171 | + ><u>Recover your eVault.</u></a |
| 172 | + > |
| 173 | + </p> |
| 174 | + </article> |
160 | 175 | {/if} |
161 | 176 | </section> |
162 | 177 |
|
|
0 commit comments