Skip to content

Commit 6e210b9

Browse files
committed
feat: recovery onboarding flow
1 parent c547387 commit 6e210b9

7 files changed

Lines changed: 674 additions & 25 deletions

File tree

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

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
// KYC panel sub-state
5252
let checkingHardware = $state(false);
5353
let showHardwareError = $state(false);
54+
let showAlreadyHaveAnonymousDrawer = $state(false);
5455
5556
// Didit verification state
5657
let diditLocalId = $state<string | null>(null);
@@ -513,21 +514,39 @@
513514
</p>
514515
</section>
515516

516-
<!-- ── Screen: already have (TBI) ────────────────────────────────────── -->
517+
<!-- ── Screen: already have ────────────────────────────────────────── -->
517518
{:else if step === "already-have"}
518-
<section
519-
class="grow flex flex-col justify-center items-center gap-4 text-center px-2"
520-
>
521-
<h4 class="text-xl font-bold">Already have an eVault?</h4>
522-
<p class="text-black-700">
523-
Restoring an existing eVault will be supported in a future
524-
update.<br /><br />
525-
<strong>TO BE IMPLEMENTED</strong>
526-
</p>
519+
<section class="grow flex flex-col justify-center gap-6">
520+
<div>
521+
<h4 class="text-xl font-bold mb-1">Already have an eVault?</h4>
522+
<p class="text-black-700 text-sm">
523+
Were you identity-verified when you set up your eVault?
524+
</p>
525+
</div>
526+
<div class="flex flex-col gap-3">
527+
<button
528+
onclick={() => goto("/recover")}
529+
class="w-full rounded-2xl border border-gray-200 bg-gray-50 p-5 text-left hover:bg-gray-100 transition-colors active:bg-gray-200"
530+
>
531+
<p class="font-semibold text-base mb-1">Yes, I verified my ID</p>
532+
<p class="text-sm text-black-500">
533+
We'll use your verified identity to find and confirm your previous eVault.
534+
</p>
535+
</button>
536+
<button
537+
onclick={() => (showAlreadyHaveAnonymousDrawer = true)}
538+
class="w-full rounded-2xl border border-gray-200 bg-gray-50 p-5 text-left hover:bg-gray-100 transition-colors active:bg-gray-200"
539+
>
540+
<p class="font-semibold text-base mb-1">No, I was anonymous</p>
541+
<p class="text-sm text-black-500">
542+
Anonymous eVaults cannot be recovered — there's no identity to verify against.
543+
</p>
544+
</button>
545+
</div>
527546
</section>
528547
<ButtonAction
529548
variant="soft"
530-
class="w-full"
549+
class="w-full mt-4"
531550
callback={() => {
532551
step = "home";
533552
}}
@@ -899,3 +918,29 @@
899918
{/if}
900919
</div>
901920
{/if}
921+
922+
<!-- ── Already-have anonymous — not possible sheet ───────────────────────── -->
923+
{#if showAlreadyHaveAnonymousDrawer}
924+
<div class="fixed inset-0 z-40 bg-black/40" aria-hidden="true"></div>
925+
<div
926+
class="fixed inset-x-0 bottom-0 z-50 bg-white rounded-t-3xl shadow-xl flex flex-col gap-4"
927+
style="padding: 1.5rem 1.5rem max(1.5rem, env(safe-area-inset-bottom));"
928+
>
929+
<div class="flex items-center gap-3">
930+
<div class="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center text-amber-600 text-lg font-bold">!</div>
931+
<h3 class="text-lg font-bold">Recovery not available</h3>
932+
</div>
933+
<p class="text-black-700 text-sm leading-relaxed">
934+
Anonymous eVaults are not recoverable. Since no identity document was linked,
935+
there's no way to confirm who you are. You'll need to create a new eVault instead.
936+
</p>
937+
<div class="flex flex-col gap-3 pt-2">
938+
<ButtonAction class="w-full" callback={() => { showAlreadyHaveAnonymousDrawer = false; step = "new-evault"; }}>
939+
Create a new eVault
940+
</ButtonAction>
941+
<ButtonAction variant="soft" class="w-full" callback={() => { showAlreadyHaveAnonymousDrawer = false; }}>
942+
Back
943+
</ButtonAction>
944+
</div>
945+
</div>
946+
{/if}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script lang="ts">
2+
let { children } = $props();
3+
</script>
4+
5+
{@render children()}
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
<script lang="ts">
2+
import { goto } from "$app/navigation";
3+
import { PUBLIC_PROVISIONER_URL } from "$env/static/public";
4+
import type { GlobalState } from "$lib/global";
5+
import { ButtonAction } from "$lib/ui";
6+
import axios from "axios";
7+
import { getContext, onMount } from "svelte";
8+
9+
type RecoveryStep =
10+
| "starting"
11+
| "didit-verification"
12+
| "searching"
13+
| "confirming"
14+
| "found"
15+
| "error";
16+
17+
let step = $state<RecoveryStep>("starting");
18+
let errorMessage = $state<string | null>(null);
19+
let errorReason = $state<"liveness_failed" | "no_match" | "face_mismatch" | "generic">("generic");
20+
21+
let portraitDataUri = $state<string | null>(null);
22+
let recoveredW3id = $state<string | null>(null);
23+
let recoveredUri = $state<string | null>(null);
24+
let faceMatchScore = $state<number | null>(null);
25+
let storing = $state(false);
26+
27+
const getGlobalState = getContext<() => GlobalState>("globalState");
28+
29+
onMount(() => {
30+
startRecovery();
31+
});
32+
33+
async function startRecovery() {
34+
step = "starting";
35+
errorMessage = null;
36+
portraitDataUri = null;
37+
recoveredW3id = null;
38+
recoveredUri = null;
39+
faceMatchScore = null;
40+
41+
try {
42+
const { data } = await axios.post(
43+
new URL("/recovery/start-session", PUBLIC_PROVISIONER_URL).toString(),
44+
);
45+
46+
if (!data.verificationUrl) {
47+
throw new Error("Backend did not return a verificationUrl");
48+
}
49+
50+
step = "didit-verification";
51+
await new Promise((r) => setTimeout(r, 50));
52+
53+
const { DiditSdk } = await import("@didit-protocol/sdk-web");
54+
const sdk = DiditSdk.shared;
55+
sdk.onComplete = handleDiditComplete;
56+
await sdk.startVerification({
57+
url: data.verificationUrl,
58+
configuration: {
59+
embedded: true,
60+
embeddedContainerId: "recovery-didit-container",
61+
},
62+
});
63+
} catch (err: any) {
64+
console.error("[RECOVERY] start-recovery error:", err);
65+
errorMessage = err instanceof Error ? err.message : "Something went wrong. Please try again.";
66+
errorReason = "generic";
67+
step = "error";
68+
}
69+
}
70+
71+
async function handleDiditComplete(result: any) {
72+
console.log("[RECOVERY] Didit onComplete:", result);
73+
74+
if (result.type === "cancelled") {
75+
goto("/onboarding");
76+
return;
77+
}
78+
79+
if (!result.session?.sessionId) {
80+
errorMessage = "The verification session did not return a session ID. Please try again.";
81+
errorReason = "generic";
82+
step = "error";
83+
return;
84+
}
85+
86+
step = "searching";
87+
88+
try {
89+
const { data } = await axios.post(
90+
new URL("/recovery/face-search", PUBLIC_PROVISIONER_URL).toString(),
91+
{ diditSessionId: result.session.sessionId },
92+
);
93+
94+
if (!data.success) {
95+
if (data.reason === "liveness_failed") {
96+
errorMessage = "We couldn't confirm you were a live person. Please try again in good lighting.";
97+
errorReason = "liveness_failed";
98+
} else {
99+
errorMessage = "We couldn't find an eVault linked to your identity. Make sure you completed identity verification when you first set up your eVault.";
100+
errorReason = "no_match";
101+
}
102+
step = "error";
103+
return;
104+
}
105+
106+
recoveredW3id = data.w3id;
107+
portraitDataUri = data.portraitDataUri ?? null;
108+
step = "confirming";
109+
110+
await runFaceMatch();
111+
} catch (err: any) {
112+
console.error("[RECOVERY] face-search error:", err);
113+
errorMessage = "Something went wrong during the search. Please try again.";
114+
errorReason = "generic";
115+
step = "error";
116+
}
117+
}
118+
119+
async function runFaceMatch() {
120+
if (!recoveredW3id || !portraitDataUri) return;
121+
122+
try {
123+
const { data } = await axios.post(
124+
new URL("/recovery/face-match", PUBLIC_PROVISIONER_URL).toString(),
125+
{ w3id: recoveredW3id, userImageBase64: portraitDataUri },
126+
);
127+
128+
if (!data.success) {
129+
if (data.reason === "no_photograph_doc") {
130+
errorMessage = "Your eVault doesn't have a photograph binding document. Please contact support.";
131+
} else {
132+
errorMessage = "Your face doesn't closely match the photo stored in your eVault. Recovery cannot proceed.";
133+
}
134+
errorReason = "face_mismatch";
135+
step = "error";
136+
return;
137+
}
138+
139+
faceMatchScore = data.score ?? null;
140+
recoveredUri = data.uri ?? null;
141+
step = "found";
142+
} catch (err: any) {
143+
console.error("[RECOVERY] face-match error:", err);
144+
errorMessage = "Something went wrong during face confirmation. Please try again.";
145+
errorReason = "generic";
146+
step = "error";
147+
}
148+
}
149+
150+
async function recoverVault() {
151+
if (!recoveredW3id || !recoveredUri) return;
152+
storing = true;
153+
try {
154+
const globalState = getGlobalState();
155+
globalState.vaultController.vault = { uri: recoveredUri, ename: recoveredW3id };
156+
globalState.userController.isFake = false;
157+
await goto("/register", { replaceState: true });
158+
} catch (err) {
159+
console.error("[RECOVERY] store failed:", err);
160+
errorMessage = "Failed to restore your eVault. Please try again.";
161+
errorReason = "generic";
162+
step = "error";
163+
} finally {
164+
storing = false;
165+
}
166+
}
167+
</script>
168+
169+
<!-- ── Didit embedded verification (full-screen) ─────────────────────────── -->
170+
{#if step === "didit-verification"}
171+
<div
172+
class="fixed inset-0 z-50 bg-white flex flex-col"
173+
style="padding-top: max(16px, env(safe-area-inset-top)); padding-bottom: max(24px, env(safe-area-inset-bottom));"
174+
>
175+
<div class="flex-none flex justify-end px-4 pt-2">
176+
<button
177+
class="text-sm text-black-500 underline"
178+
onclick={() => goto("/onboarding")}
179+
>
180+
Cancel
181+
</button>
182+
</div>
183+
<div id="recovery-didit-container" class="flex-1 w-full"></div>
184+
</div>
185+
{:else}
186+
<!-- Background page — spinner while preparing / searching / confirming -->
187+
<main
188+
class="flex min-h-screen flex-col items-center justify-center bg-background px-6 gap-4"
189+
style="padding-top: max(5.2svh, env(safe-area-inset-top)); padding-bottom: max(2rem, env(safe-area-inset-bottom));"
190+
>
191+
{#if step === "starting" || step === "searching" || step === "confirming"}
192+
<div class="h-14 w-14 animate-spin rounded-full border-4 border-gray-200 border-t-primary-500"></div>
193+
<p class="font-semibold text-black-900 text-center">
194+
{#if step === "starting"}
195+
Preparing verification…
196+
{:else if step === "searching"}
197+
Finding your eVault…
198+
{:else}
199+
Confirming your identity…
200+
{/if}
201+
</p>
202+
<p class="text-sm text-black-700 text-center">
203+
{#if step === "starting"}
204+
Setting up your recovery session.
205+
{:else if step === "searching"}
206+
Looking for an eVault linked to your identity.
207+
{:else}
208+
Matching your face against the stored photo.
209+
{/if}
210+
</p>
211+
{#if step === "starting"}
212+
<button
213+
onclick={() => goto("/onboarding")}
214+
class="mt-4 text-sm text-black-500 underline"
215+
>
216+
Cancel
217+
</button>
218+
{/if}
219+
{/if}
220+
</main>
221+
{/if}
222+
223+
<!-- ── "eVault found" bottom sheet ─────────────────────────────────────────── -->
224+
{#if step === "found"}
225+
<div class="fixed inset-0 z-40 bg-black/40" aria-hidden="true"></div>
226+
<div
227+
class="fixed inset-x-0 bottom-0 z-50 bg-white rounded-t-3xl shadow-xl flex flex-col gap-4"
228+
style="padding: 1.5rem 1.5rem max(1.5rem, env(safe-area-inset-bottom));"
229+
>
230+
<div class="flex items-center gap-3">
231+
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 text-lg font-bold">
232+
233+
</div>
234+
<h3 class="text-lg font-bold">eVault Found</h3>
235+
</div>
236+
<p class="text-black-700 text-sm">
237+
We confirmed your identity. Here's your previous eVault — tap Recover to restore it.
238+
You'll be asked to set a new PIN to protect it.
239+
</p>
240+
<div class="rounded-2xl bg-gray-50 border border-gray-200 p-4 flex flex-col gap-1">
241+
<p class="text-xs text-black-500 font-medium uppercase tracking-wide">Your eName</p>
242+
<p class="font-mono text-sm font-semibold text-black-900 break-all">{recoveredW3id}</p>
243+
{#if faceMatchScore !== null}
244+
<p class="text-xs text-black-500 mt-1">Face match confidence: {faceMatchScore.toFixed(1)}%</p>
245+
{/if}
246+
</div>
247+
<div class="flex flex-col gap-3 pt-2">
248+
<ButtonAction class="w-full" blockingClick callback={recoverVault}>
249+
{storing ? "Restoring…" : "Recover this eVault"}
250+
</ButtonAction>
251+
<ButtonAction
252+
variant="soft"
253+
class="w-full"
254+
callback={() => goto("/onboarding")}
255+
>
256+
That's not me — cancel
257+
</ButtonAction>
258+
</div>
259+
</div>
260+
{/if}
261+
262+
<!-- ── Error bottom sheet ──────────────────────────────────────────────────── -->
263+
{#if step === "error"}
264+
<div class="fixed inset-0 z-40 bg-black/40" aria-hidden="true"></div>
265+
<div
266+
class="fixed inset-x-0 bottom-0 z-50 bg-white rounded-t-3xl shadow-xl flex flex-col gap-4"
267+
style="padding: 1.5rem 1.5rem max(1.5rem, env(safe-area-inset-bottom));"
268+
>
269+
<div class="flex items-center gap-3">
270+
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center text-red-600 text-lg font-bold">
271+
272+
</div>
273+
<h3 class="text-lg font-bold">
274+
{#if errorReason === "liveness_failed"}
275+
Liveness Check Failed
276+
{:else if errorReason === "no_match"}
277+
No eVault Found
278+
{:else if errorReason === "face_mismatch"}
279+
Face Mismatch
280+
{:else}
281+
Something Went Wrong
282+
{/if}
283+
</h3>
284+
</div>
285+
<p class="text-black-700 text-sm">{errorMessage}</p>
286+
<div class="flex flex-col gap-3 pt-2">
287+
{#if errorReason !== "no_match" && errorReason !== "face_mismatch"}
288+
<ButtonAction class="w-full" callback={startRecovery}>
289+
Try Again
290+
</ButtonAction>
291+
{/if}
292+
<ButtonAction variant="soft" class="w-full" callback={() => goto("/onboarding")}>
293+
Back to Onboarding
294+
</ButtonAction>
295+
</div>
296+
</div>
297+
{/if}

0 commit comments

Comments
 (0)