Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

253 changes: 134 additions & 119 deletions infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,129 +1,137 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { keyboardInset } from "$lib/actions/keyboardInset";
import type { GlobalState } from "$lib/global";
import { LoadingSheet, PinDots } from "$lib/ui";
import * as Button from "$lib/ui/Button";
import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin";
import {
type AuthOptions,
authenticate,
checkStatus,
} from "@tauri-apps/plugin-biometric";
import { getContext, onMount } from "svelte";
import StepHeader from "../onboarding/steps/StepHeader.svelte";

// Splash sets this when it has already tried biometric over its own screen.
// /login then skips re-prompting and just shows the PIN UI.
const BIOMETRIC_ATTEMPTED_KEY = "biometricAttemptedOnSplash";

let pin = $state("");
let isError = $state(false);
let isPostAuthLoading = $state(false);
let hasPendingDeepLink = $state(false);
let pinInput = $state<HTMLInputElement | undefined>(undefined);

// Refocus the hidden PIN input on background taps so the user doesn't have
// to aim for the dots themselves to summon the keyboard. We skip taps that
// land on interactive elements so their own click handlers (Clear PIN, back
// chevron, etc.) still behave normally.
function handleBackgroundClick(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('button, a, [role="button"]')) return;
pinInput?.focus({ preventScroll: true });
}

const getGlobalState = getContext<() => GlobalState | undefined>("globalState");
let globalState: GlobalState | undefined = $state(undefined);

const authOpts: AuthOptions = {
allowDeviceCredential: false,
cancelTitle: "Cancel",
// iOS
fallbackTitle: "Please enter your PIN",
// Android
title: "Login",
subtitle: "Please authenticate to continue",
confirmationRequired: true,
};

async function clearPin() {
if (isPostAuthLoading) return;
pin = "";
isError = false;
}

async function verifyAndAdvance(currentPin: string) {
if (isPostAuthLoading) return;
if (!globalState) return;
if (currentPin.length !== 4) return;

isError = false;
isPostAuthLoading = true;

const ok = await globalState.securityController.verifyPin(currentPin);
if (!ok) {
isError = true;
pin = "";
isPostAuthLoading = false;
return;
import { goto } from "$app/navigation";
import { keyboardInset } from "$lib/actions/keyboardInset";
import type { GlobalState } from "$lib/global";
import { LoadingSheet, PinDots } from "$lib/ui";
import * as Button from "$lib/ui/Button";
import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin";
import {
type AuthOptions,
authenticate,
checkStatus,
} from "@tauri-apps/plugin-biometric";
import { getContext, onMount } from "svelte";
import StepHeader from "../onboarding/steps/StepHeader.svelte";

// Splash sets this when it has already tried biometric over its own screen.
// /login then skips re-prompting and just shows the PIN UI.
const BIOMETRIC_ATTEMPTED_KEY = "biometricAttemptedOnSplash";

let pin = $state("");
let isError = $state(false);
let isPostAuthLoading = $state(false);
let hasPendingDeepLink = $state(false);
let pinInput = $state<HTMLInputElement | undefined>(undefined);

// Refocus the hidden PIN input on background taps so the user doesn't have
// to aim for the dots themselves to summon the keyboard. We skip taps that
// land on interactive elements so their own click handlers (Clear PIN, back
// chevron, etc.) still behave normally.
function handleBackgroundClick(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('button, a, [role="button"]')) return;
pinInput?.focus({ preventScroll: true });
}

await continueAfterSuccessfulAuth(globalState);
}

$effect(() => {
if (pin.length === 4) verifyAndAdvance(pin);
});

onMount(async () => {
// Root +layout creates globalState in its own onMount (which runs after
// children). Poll until it's available — same pattern as (app)/+layout.
let gs = getGlobalState();
let retries = 0;
while (!gs && retries < 50) {
await new Promise((r) => setTimeout(r, 100));
gs = getGlobalState();
retries++;
}
if (!gs) {
console.error("Global state never became available");
await goto("/");
return;
}
globalState = gs;

const pendingDeepLink = sessionStorage.getItem("pendingDeepLink");
hasPendingDeepLink = !!pendingDeepLink;

// If the splash already prompted biometric over its own screen, skip the
// retry here and let the user enter their PIN. The flag survives the
// route transition but is single-use.
const biometricHandledBySplash =
sessionStorage.getItem(BIOMETRIC_ATTEMPTED_KEY) === "true";
if (biometricHandledBySplash) {
sessionStorage.removeItem(BIOMETRIC_ATTEMPTED_KEY);
return;
const getGlobalState =
getContext<() => GlobalState | undefined>("globalState");
let globalState: GlobalState | undefined = $state(undefined);

const authOpts: AuthOptions = {
allowDeviceCredential: false,
cancelTitle: "Cancel",
// iOS
fallbackTitle: "Please enter your PIN",
// Android
title: "Login",
subtitle: "Please authenticate to continue",
confirmationRequired: true,
};

async function clearPin() {
if (isPostAuthLoading) return;
pin = "";
isError = false;
}

// Try biometric first if available.
if (
(await gs.securityController.biometricSupport) &&
(await checkStatus()).isAvailable
) {
async function verifyAndAdvance(currentPin: string) {
if (isPostAuthLoading) return;
if (!globalState) return;
if (currentPin.length !== 4) return;

isError = false;
isPostAuthLoading = true;

try {
await authenticate(
"You must authenticate with PIN first",
authOpts,
);
isPostAuthLoading = true;
await continueAfterSuccessfulAuth(gs);
const ok = await globalState.securityController.verifyPin(currentPin);
if (!ok) {
isError = true;
pin = "";
return;
}

await continueAfterSuccessfulAuth(globalState);
} catch (e) {
console.error("Biometric authentication failed", e);
console.error("PIN verification failed", e);
isError = true;
pin = "";
} finally {
isPostAuthLoading = false;
}
}
Comment thread
coodos marked this conversation as resolved.
});

$effect(() => {
if (pin.length === 4) verifyAndAdvance(pin);
});

onMount(async () => {
// Root +layout creates globalState in its own onMount (which runs after
// children). Poll until it's available — same pattern as (app)/+layout.
let gs = getGlobalState();
let retries = 0;
while (!gs && retries < 50) {
await new Promise((r) => setTimeout(r, 100));
gs = getGlobalState();
retries++;
}
if (!gs) {
console.error("Global state never became available");
await goto("/");
return;
}
globalState = gs;

const pendingDeepLink = sessionStorage.getItem("pendingDeepLink");
hasPendingDeepLink = !!pendingDeepLink;

// If the splash already prompted biometric over its own screen, skip the
// retry here and let the user enter their PIN. The flag survives the
// route transition but is single-use.
const biometricHandledBySplash =
sessionStorage.getItem(BIOMETRIC_ATTEMPTED_KEY) === "true";
if (biometricHandledBySplash) {
sessionStorage.removeItem(BIOMETRIC_ATTEMPTED_KEY);
return;
}

// Try biometric first if available.
if (
(await gs.securityController.biometricSupport) &&
(await checkStatus()).isAvailable
) {
try {
await authenticate(
"You must authenticate with PIN first",
authOpts,
);
isPostAuthLoading = true;
await continueAfterSuccessfulAuth(gs);
} catch (e) {
console.error("Biometric authentication failed", e);
isPostAuthLoading = false;
}
}
});
</script>

<!-- The PIN input is the only meaningful interaction here; keyboard users
Expand Down Expand Up @@ -154,9 +162,16 @@ onMount(async () => {
<PinDots bind:pin bind:inputEl={pinInput} />

{#if isError}
<p class="text-danger text-sm font-medium" role="alert">
Your PIN does not match, try again.
</p>
<article class="flex flex-col items-center justify-center gap-2">
<p class="text-danger text-sm font-medium" role="alert">
Your PIN does not match, try again.
</p>
<p class="text-black-700 opacity-50 text-sm font-medium">
Forgot your pin? <a href="/recover"
><u>Recover your eVault.</u></a
>
</p>
</article>
{/if}
</section>

Expand Down
Loading