Skip to content

Commit f2e6e61

Browse files
committed
Streamlined the login flow
1 parent d269cc1 commit f2e6e61

5 files changed

Lines changed: 204 additions & 120 deletions

File tree

infrastructure/eid-wallet/src/lib/fragments/SplashScreen/SplashScreen.svelte

Lines changed: 34 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,13 @@ interface ISplashScreenProps {
1111
showDrawer?: boolean;
1212
oncreate?: () => void;
1313
onrestore?: () => void;
14-
/** When provided, the drawer renders a single "Continue" button instead
15-
* of the Create / Restore pair (returning-user variant). */
16-
oncontinue?: () => void;
1714
}
1815
1916
const {
2017
open = false,
2118
showDrawer = false,
2219
oncreate,
2320
onrestore,
24-
oncontinue,
2521
}: ISplashScreenProps = $props();
2622
2723
// Roboto Condensed ships with font-display: swap, so without this gate the
@@ -97,52 +93,42 @@ onMount(async () => {
9793
class="absolute inset-x-0 bottom-0 bg-white rounded-t-3xl px-5 pt-5 flex flex-col gap-3"
9894
style="padding-bottom: max(20px, env(safe-area-inset-bottom));"
9995
>
100-
{#if oncontinue}
101-
<ButtonAction
102-
variant="solid"
103-
callback={oncontinue}
104-
class="w-full uppercase tracking-wide active:bg-primary-400"
105-
>
106-
Continue
107-
</ButtonAction>
108-
{:else}
109-
<ButtonAction
110-
variant="solid"
111-
callback={oncreate}
112-
class="w-full uppercase tracking-wide active:bg-primary-400"
113-
>
114-
Create Digital Self
115-
</ButtonAction>
116-
<ButtonAction
117-
variant="soft"
118-
callback={onrestore}
119-
class="w-full uppercase tracking-wide text-black active:bg-primary-200"
96+
<ButtonAction
97+
variant="solid"
98+
callback={oncreate}
99+
class="w-full uppercase tracking-wide active:bg-primary-400"
100+
>
101+
Create Digital Self
102+
</ButtonAction>
103+
<ButtonAction
104+
variant="soft"
105+
callback={onrestore}
106+
class="w-full uppercase tracking-wide text-black active:bg-primary-200"
107+
>
108+
Restore Digital Self
109+
</ButtonAction>
110+
<p
111+
class="text-center font-medium text-md text-black-700/50 leading-normal"
112+
>
113+
By continuing you agree to our
114+
<a
115+
href="https://metastate.foundation/"
116+
target="_blank"
117+
rel="noopener noreferrer"
118+
class="text-primary"
120119
>
121-
Restore Digital Self
122-
</ButtonAction>
123-
<p
124-
class="text-center font-medium text-md text-black-700/50 leading-normal"
120+
Terms &amp; Conditions
121+
</a>
122+
and
123+
<a
124+
href="https://metastate.foundation/"
125+
target="_blank"
126+
rel="noopener noreferrer"
127+
class="text-primary"
125128
>
126-
By continuing you agree to our
127-
<a
128-
href="https://metastate.foundation/"
129-
target="_blank"
130-
rel="noopener noreferrer"
131-
class="text-primary"
132-
>
133-
Terms &amp; Conditions
134-
</a>
135-
and
136-
<a
137-
href="https://metastate.foundation/"
138-
target="_blank"
139-
rel="noopener noreferrer"
140-
class="text-primary"
141-
>
142-
Privacy Policy
143-
</a>
144-
</p>
145-
{/if}
129+
Privacy Policy
130+
</a>
131+
</p>
146132
</div>
147133
{/if}
148134
</div>

infrastructure/eid-wallet/src/lib/ui/PinDots/PinDots.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@ interface IPinDotsProps {
55
pin: string;
66
autofocus?: boolean;
77
class?: string;
8+
/** Bind a reference to the underlying hidden input so the parent can
9+
* trigger focus from elsewhere (e.g. a background tap on /login). */
10+
inputEl?: HTMLInputElement | undefined;
811
}
912
1013
let {
1114
pin = $bindable(""),
1215
autofocus = true,
1316
class: classes = "",
17+
inputEl = $bindable<HTMLInputElement | undefined>(undefined),
1418
}: IPinDotsProps = $props();
1519
16-
let inputEl: HTMLInputElement | undefined = $state();
17-
1820
// Android WebView only attaches its IME input connection after first paint;
1921
// focus before that is a no-op for the soft keyboard.
2022
onMount(async () => {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { goto } from "$app/navigation";
2+
import type { GlobalState } from "$lib/global";
3+
4+
/**
5+
* Shared post-authentication routine: fires the background eVault chores
6+
* (health check, public-key sync, push registration) and routes the user
7+
* either to the deep-link target waiting in sessionStorage or to /main.
8+
*
9+
* Called from both the splash (when biometric auth succeeds over the
10+
* splash screen) and from /login (after PIN or fallback biometric).
11+
* Keeping the logic here means we don't have to flash the user through
12+
* /login on biometric success.
13+
*/
14+
export async function continueAfterSuccessfulAuth(
15+
gs: GlobalState,
16+
): Promise<void> {
17+
// Fire-and-forget post-login chores. They hit the network with no client
18+
// timeout, so awaiting them here can strand the user on a spinner — the
19+
// app pages will retry as needed.
20+
try {
21+
const vault = await gs.vaultController.vault;
22+
if (vault?.ename) {
23+
const ename = vault.ename;
24+
void gs.vaultController
25+
.checkHealth(ename)
26+
.then((health) => {
27+
if (!health.healthy) {
28+
console.warn(
29+
"eVault health check failed:",
30+
health.error,
31+
);
32+
}
33+
})
34+
.catch((error) =>
35+
console.error("eVault health check error:", error),
36+
);
37+
void gs.vaultController
38+
.syncPublicKey(ename)
39+
.catch((error) =>
40+
console.error("Error syncing public key:", error),
41+
);
42+
void gs.notificationService
43+
.registerDevice(ename)
44+
.catch((error) =>
45+
console.error(
46+
"Error registering device for notifications:",
47+
error,
48+
),
49+
);
50+
}
51+
} catch (error) {
52+
console.error("Error reading vault during login:", error);
53+
}
54+
55+
const pendingDeepLink = sessionStorage.getItem("pendingDeepLink");
56+
if (pendingDeepLink) {
57+
try {
58+
sessionStorage.setItem("deepLinkData", pendingDeepLink);
59+
sessionStorage.removeItem("pendingDeepLink");
60+
await goto("/scan-qr");
61+
return;
62+
} catch (error) {
63+
console.error("Error processing pending deep link:", error);
64+
sessionStorage.removeItem("pendingDeepLink");
65+
}
66+
}
67+
68+
await goto("/main");
69+
}

infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte

Lines changed: 34 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { keyboardInset } from "$lib/actions/keyboardInset";
44
import type { GlobalState } from "$lib/global";
55
import { LoadingSheet, PinDots } from "$lib/ui";
66
import * as Button from "$lib/ui/Button";
7+
import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin";
78
import {
89
type AuthOptions,
910
authenticate,
@@ -12,10 +13,25 @@ import {
1213
import { getContext, onMount } from "svelte";
1314
import StepHeader from "../onboarding/steps/StepHeader.svelte";
1415
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+
1520
let pin = $state("");
1621
let isError = $state(false);
1722
let isPostAuthLoading = $state(false);
1823
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+
}
1935
2036
const getGlobalState = getContext<() => GlobalState | undefined>("globalState");
2137
let globalState: GlobalState | undefined = $state(undefined);
@@ -37,61 +53,6 @@ async function clearPin() {
3753
isError = false;
3854
}
3955
40-
async function continueAfterSuccessfulAuth(gs: GlobalState) {
41-
// Fire-and-forget post-login chores. These hit the network with no client
42-
// timeout, so awaiting them here can strand the user on the "Logging you
43-
// in…" spinner indefinitely. The app pages will retry as needed.
44-
try {
45-
const vault = await gs.vaultController.vault;
46-
if (vault?.ename) {
47-
const ename = vault.ename;
48-
void gs.vaultController
49-
.checkHealth(ename)
50-
.then((health) => {
51-
if (!health.healthy) {
52-
console.warn(
53-
"eVault health check failed:",
54-
health.error,
55-
);
56-
}
57-
})
58-
.catch((error) =>
59-
console.error("eVault health check error:", error),
60-
);
61-
void gs.vaultController
62-
.syncPublicKey(ename)
63-
.catch((error) =>
64-
console.error("Error syncing public key:", error),
65-
);
66-
void gs.notificationService
67-
.registerDevice(ename)
68-
.catch((error) =>
69-
console.error(
70-
"Error registering device for notifications:",
71-
error,
72-
),
73-
);
74-
}
75-
} catch (error) {
76-
console.error("Error reading vault during login:", error);
77-
}
78-
79-
const pendingDeepLink = sessionStorage.getItem("pendingDeepLink");
80-
if (pendingDeepLink) {
81-
try {
82-
sessionStorage.setItem("deepLinkData", pendingDeepLink);
83-
sessionStorage.removeItem("pendingDeepLink");
84-
await goto("/scan-qr");
85-
return;
86-
} catch (error) {
87-
console.error("Error processing pending deep link:", error);
88-
sessionStorage.removeItem("pendingDeepLink");
89-
}
90-
}
91-
92-
await goto("/main");
93-
}
94-
9556
async function verifyAndAdvance(currentPin: string) {
9657
if (isPostAuthLoading) return;
9758
if (!globalState) return;
@@ -135,6 +96,16 @@ onMount(async () => {
13596
const pendingDeepLink = sessionStorage.getItem("pendingDeepLink");
13697
hasPendingDeepLink = !!pendingDeepLink;
13798
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;
107+
}
108+
138109
// Try biometric first if available.
139110
if (
140111
(await gs.securityController.biometricSupport) &&
@@ -155,8 +126,15 @@ onMount(async () => {
155126
});
156127
</script>
157128

129+
<!-- The PIN input is the only meaningful interaction here; keyboard users
130+
focus it directly via tab. The pointer-only listener just refocuses the
131+
same input when sighted users tap the background, so there's no
132+
keyboard interaction worth mirroring. -->
133+
<!-- svelte-ignore a11y_click_events_have_key_events -->
134+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
158135
<main
159136
use:keyboardInset
137+
onclick={handleBackgroundClick}
160138
class="h-dvh overflow-hidden px-[5vw] flex flex-col bg-white"
161139
style="padding-top: max(2svh, env(safe-area-inset-top)); padding-bottom: calc(max(16px, env(safe-area-inset-bottom)) + var(--kb-inset, 0px));"
162140
>
@@ -173,7 +151,7 @@ onMount(async () => {
173151
{/if}
174152

175153
<section class="flex-1 flex flex-col items-center justify-center gap-6">
176-
<PinDots bind:pin />
154+
<PinDots bind:pin bind:inputEl={pinInput} />
177155

178156
{#if isError}
179157
<p class="text-danger text-sm font-medium" role="alert">

0 commit comments

Comments
 (0)