@seamless-auth/svelte 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +202 -0
- package/README.md +253 -2
- package/dist/auth.svelte.d.ts +170 -0
- package/dist/auth.svelte.js +208 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +6 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +35 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +21 -0
- package/dist/kit/index.d.ts +55 -0
- package/dist/kit/index.js +121 -0
- package/dist/kit/watch.svelte.d.ts +10 -0
- package/dist/kit/watch.svelte.js +28 -0
- package/dist/navigation.d.ts +40 -0
- package/dist/navigation.js +22 -0
- package/dist/screens/AuthLayout.svelte +38 -0
- package/dist/screens/AuthLayout.svelte.d.ts +8 -0
- package/dist/screens/FallbackOptions.svelte +69 -0
- package/dist/screens/FallbackOptions.svelte.d.ts +13 -0
- package/dist/screens/Login.svelte +253 -0
- package/dist/screens/Login.svelte.d.ts +3 -0
- package/dist/screens/MagicLinkSent.svelte +74 -0
- package/dist/screens/MagicLinkSent.svelte.d.ts +3 -0
- package/dist/screens/OAuthCallback.svelte +69 -0
- package/dist/screens/OAuthCallback.svelte.d.ts +3 -0
- package/dist/screens/OAuthProviderButtons.svelte +49 -0
- package/dist/screens/OAuthProviderButtons.svelte.d.ts +6 -0
- package/dist/screens/OtpInput.svelte +101 -0
- package/dist/screens/OtpInput.svelte.d.ts +10 -0
- package/dist/screens/PasskeyLogin.svelte +34 -0
- package/dist/screens/PasskeyLogin.svelte.d.ts +3 -0
- package/dist/screens/RegisterPasskey.svelte +110 -0
- package/dist/screens/RegisterPasskey.svelte.d.ts +3 -0
- package/dist/screens/VerifyMagicLink.svelte +104 -0
- package/dist/screens/VerifyMagicLink.svelte.d.ts +3 -0
- package/dist/screens/VerifyOtp.svelte +141 -0
- package/dist/screens/VerifyOtp.svelte.d.ts +7 -0
- package/dist/seamless-auth.css +260 -0
- package/dist/styles.generated.d.ts +2 -0
- package/dist/styles.generated.js +3 -0
- package/package.json +76 -4
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Copyright © 2026 Fells Code, LLC
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
See LICENSE file in the project root for full license information
|
|
5
|
+
-->
|
|
6
|
+
<!-- One box per character of a one-time code. Bind the code with bind:value. -->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { isOtpCharacter, otpCharacters, type OtpInputMode } from '@seamless-auth/client';
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
value = $bindable(''),
|
|
12
|
+
length = 6,
|
|
13
|
+
mode = 'numeric',
|
|
14
|
+
name = 'otp',
|
|
15
|
+
}: { value?: string; length?: number; mode?: OtpInputMode; name?: string } = $props();
|
|
16
|
+
|
|
17
|
+
const boxes: (HTMLInputElement | undefined)[] = $state([]);
|
|
18
|
+
const chars = $derived(Array.from({ length }, (_, i) => value[i] ?? ''));
|
|
19
|
+
|
|
20
|
+
const focus = (index: number) => boxes[index]?.focus();
|
|
21
|
+
const write = (next: string[]) => {
|
|
22
|
+
value = next.join('');
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function fill(index: number, input: string) {
|
|
26
|
+
const incoming = otpCharacters(input, mode).slice(0, length);
|
|
27
|
+
const next = [...chars];
|
|
28
|
+
|
|
29
|
+
incoming.forEach((char, offset) => {
|
|
30
|
+
if (index + offset < next.length) next[index + offset] = char;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
write(next);
|
|
34
|
+
focus(Math.min(index + incoming.length, length - 1));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function onInput(index: number, event: Event) {
|
|
38
|
+
const box = event.currentTarget as HTMLInputElement;
|
|
39
|
+
const typed = box.value;
|
|
40
|
+
|
|
41
|
+
if (typed.length > 1) {
|
|
42
|
+
fill(index, typed);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typed && !isOtpCharacter(typed, mode)) {
|
|
47
|
+
// The value only flows down, so a refused character has to be taken back
|
|
48
|
+
// out of the element itself.
|
|
49
|
+
box.value = chars[index];
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const next = [...chars];
|
|
54
|
+
next[index] = typed;
|
|
55
|
+
write(next);
|
|
56
|
+
|
|
57
|
+
if (typed) focus(index + 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function onKeydown(index: number, event: KeyboardEvent) {
|
|
61
|
+
if (event.key === 'Backspace') {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
const next = [...chars];
|
|
64
|
+
|
|
65
|
+
if (next[index]) {
|
|
66
|
+
next[index] = '';
|
|
67
|
+
write(next);
|
|
68
|
+
} else if (index > 0) {
|
|
69
|
+
next[index - 1] = '';
|
|
70
|
+
write(next);
|
|
71
|
+
focus(index - 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (event.key === 'ArrowLeft' && index > 0) focus(index - 1);
|
|
76
|
+
if (event.key === 'ArrowRight' && index < length - 1) focus(index + 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function onPaste(event: ClipboardEvent) {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
fill(0, event.clipboardData?.getData('text') ?? '');
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<div class="sa-otp" role="group" aria-label="One-time password input" onpaste={onPaste}>
|
|
86
|
+
{#each chars as char, i (i)}
|
|
87
|
+
<input
|
|
88
|
+
bind:this={boxes[i]}
|
|
89
|
+
class="sa-otp-input"
|
|
90
|
+
type="text"
|
|
91
|
+
inputmode={mode === 'numeric' ? 'numeric' : 'text'}
|
|
92
|
+
autocomplete="one-time-code"
|
|
93
|
+
name={`${name}-${i}`}
|
|
94
|
+
maxlength="1"
|
|
95
|
+
value={char}
|
|
96
|
+
aria-label={`Digit ${i + 1}`}
|
|
97
|
+
oninput={event => onInput(i, event)}
|
|
98
|
+
onkeydown={event => onKeydown(i, event)}
|
|
99
|
+
/>
|
|
100
|
+
{/each}
|
|
101
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type OtpInputMode } from '@seamless-auth/client';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
value?: string;
|
|
4
|
+
length?: number;
|
|
5
|
+
mode?: OtpInputMode;
|
|
6
|
+
name?: string;
|
|
7
|
+
};
|
|
8
|
+
declare const OtpInput: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
9
|
+
type OtpInput = ReturnType<typeof OtpInput>;
|
|
10
|
+
export default OtpInput;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Copyright © 2026 Fells Code, LLC
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
See LICENSE file in the project root for full license information
|
|
5
|
+
-->
|
|
6
|
+
<!-- Sign in with a passkey alone, without an identifier first. -->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { getAuthNavigator, getSeamlessAuth } from '../context.js';
|
|
9
|
+
import AuthLayout from './AuthLayout.svelte';
|
|
10
|
+
|
|
11
|
+
const auth = getSeamlessAuth();
|
|
12
|
+
const navigator = getAuthNavigator();
|
|
13
|
+
let error = $state('');
|
|
14
|
+
|
|
15
|
+
async function signIn() {
|
|
16
|
+
error = '';
|
|
17
|
+
const result = await auth.handlePasskeyLogin();
|
|
18
|
+
|
|
19
|
+
if (result.error) {
|
|
20
|
+
error = 'Passkey sign-in could not be completed. Try another sign-in method.';
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await navigator.toApp();
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<AuthLayout>
|
|
29
|
+
<h2 class="sa-heading">Login with Passkey</h2>
|
|
30
|
+
<button type="button" class="sa-button" onclick={signIn}>Use Passkey</button>
|
|
31
|
+
{#if error}
|
|
32
|
+
<p class="sa-error">{error}</p>
|
|
33
|
+
{/if}
|
|
34
|
+
</AuthLayout>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Copyright © 2026 Fells Code, LLC
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
See LICENSE file in the project root for full license information
|
|
5
|
+
-->
|
|
6
|
+
<!--
|
|
7
|
+
Offers passkey enrolment to a user who has just signed in. The session already
|
|
8
|
+
exists by the time this renders, so a passkey is an addition to it, which is
|
|
9
|
+
what makes leaving without one a legitimate way to finish.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import {
|
|
13
|
+
enrollPasskey,
|
|
14
|
+
hasNonPasskeyLoginMethod,
|
|
15
|
+
safeReturnPath,
|
|
16
|
+
type PasskeyAttachment,
|
|
17
|
+
} from '@seamless-auth/client';
|
|
18
|
+
import { onMount } from 'svelte';
|
|
19
|
+
|
|
20
|
+
import { getAuthNavigator, getSeamlessAuth } from '../context.js';
|
|
21
|
+
import AuthLayout from './AuthLayout.svelte';
|
|
22
|
+
|
|
23
|
+
const auth = getSeamlessAuth();
|
|
24
|
+
const navigator = getAuthNavigator();
|
|
25
|
+
// The OAuth callback hands over the caller's destination when the API asks for
|
|
26
|
+
// enrolment first. Without one, the configured signedInPath applies.
|
|
27
|
+
const returnTo = navigator.state().returnTo;
|
|
28
|
+
const finish = () =>
|
|
29
|
+
returnTo === undefined ? navigator.toApp() : navigator.toLocation(safeReturnPath(returnTo));
|
|
30
|
+
|
|
31
|
+
let status = $state<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
32
|
+
let message = $state('');
|
|
33
|
+
|
|
34
|
+
// With passkey as the only enabled method, a user who skipped would have no
|
|
35
|
+
// way back into the account they just made.
|
|
36
|
+
const canSkip = $derived(hasNonPasskeyLoginMethod(auth.loginMethods));
|
|
37
|
+
const busy = $derived(status === 'loading');
|
|
38
|
+
|
|
39
|
+
onMount(() => {
|
|
40
|
+
void auth.checkPasskeySupport();
|
|
41
|
+
void auth.loadLoginMethods();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
async function register(attachment?: PasskeyAttachment) {
|
|
45
|
+
status = 'loading';
|
|
46
|
+
|
|
47
|
+
const { error } = await enrollPasskey(
|
|
48
|
+
{ client: auth.client, refreshSession: auth.refreshSession },
|
|
49
|
+
attachment
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (error) {
|
|
53
|
+
status = 'error';
|
|
54
|
+
message = error;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
status = 'success';
|
|
59
|
+
message = 'Passkey registered successfully.';
|
|
60
|
+
await finish();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function finishWithoutPasskey() {
|
|
64
|
+
await auth.refreshSession();
|
|
65
|
+
await finish();
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<AuthLayout>
|
|
70
|
+
{#if auth.passkeySupportLoading || auth.loginMethodsLoading}
|
|
71
|
+
<div class="sa-center">
|
|
72
|
+
<div class="sa-spinner" aria-hidden="true"></div>
|
|
73
|
+
<span>Checking for Passkey Support...</span>
|
|
74
|
+
</div>
|
|
75
|
+
{:else if !auth.passkeySupported}
|
|
76
|
+
<h2 class="sa-heading">Passkeys are not available here</h2>
|
|
77
|
+
<p class="sa-description">
|
|
78
|
+
{canSkip
|
|
79
|
+
? 'This device does not support passkeys. You can continue without one and add a passkey later from a device that does.'
|
|
80
|
+
: 'This device does not support passkeys, and this application requires one to sign in. Try again from a device or browser that supports them.'}
|
|
81
|
+
</p>
|
|
82
|
+
{#if canSkip}
|
|
83
|
+
<button type="button" class="sa-button" onclick={finishWithoutPasskey}>Continue</button>
|
|
84
|
+
{/if}
|
|
85
|
+
{:else}
|
|
86
|
+
<h2 class="sa-heading">Secure Your Account with a Passkey</h2>
|
|
87
|
+
<p class="sa-description">
|
|
88
|
+
Your device supports passkeys! Register one to skip passwords forever.
|
|
89
|
+
</p>
|
|
90
|
+
<button type="button" class="sa-button" disabled={busy} onclick={() => register()}>
|
|
91
|
+
{busy ? 'Registering...' : 'Register Passkey'}
|
|
92
|
+
</button>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
class="sa-secondary"
|
|
96
|
+
disabled={busy}
|
|
97
|
+
onclick={() => register('cross-platform')}
|
|
98
|
+
>
|
|
99
|
+
Use a security key instead
|
|
100
|
+
</button>
|
|
101
|
+
{#if message}
|
|
102
|
+
<p class={status === 'success' ? 'sa-success' : 'sa-error'} role="status">{message}</p>
|
|
103
|
+
{/if}
|
|
104
|
+
{#if canSkip}
|
|
105
|
+
<button type="button" class="sa-link" disabled={busy} onclick={finishWithoutPasskey}>
|
|
106
|
+
Skip for now
|
|
107
|
+
</button>
|
|
108
|
+
{/if}
|
|
109
|
+
{/if}
|
|
110
|
+
</AuthLayout>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Copyright © 2026 Fells Code, LLC
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
See LICENSE file in the project root for full license information
|
|
5
|
+
-->
|
|
6
|
+
<!--
|
|
7
|
+
Where an emailed magic link lands. Verifies the link once, tells the tab that
|
|
8
|
+
asked for it, and signs this tab in too when the link was opened in the same
|
|
9
|
+
browser.
|
|
10
|
+
-->
|
|
11
|
+
<script module lang="ts">
|
|
12
|
+
// Tokens this page has already spent. A link can be used once, and a screen
|
|
13
|
+
// that mounts again (a Back press, a wrapper that remounts) must not send it
|
|
14
|
+
// a second time.
|
|
15
|
+
const spent = new Set<string>();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { finishMagicLinkSignIn, type MagicLinkOutcome } from '@seamless-auth/client';
|
|
20
|
+
import { onMount } from 'svelte';
|
|
21
|
+
|
|
22
|
+
import { getAuthNavigator, getSeamlessAuth } from '../context.js';
|
|
23
|
+
import AuthLayout from './AuthLayout.svelte';
|
|
24
|
+
|
|
25
|
+
const auth = getSeamlessAuth();
|
|
26
|
+
const navigator = getAuthNavigator();
|
|
27
|
+
let outcome = $state<MagicLinkOutcome | null>(null);
|
|
28
|
+
let error = $state('');
|
|
29
|
+
|
|
30
|
+
// Browser only. A server render would spend the single-use link in a request
|
|
31
|
+
// that carries none of this browser's cookies.
|
|
32
|
+
onMount(() => {
|
|
33
|
+
let active = true;
|
|
34
|
+
let redirect: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
|
|
36
|
+
void (async () => {
|
|
37
|
+
const token = navigator.query('token');
|
|
38
|
+
|
|
39
|
+
if (!token) {
|
|
40
|
+
error = 'Missing token for verification.';
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (spent.has(token)) {
|
|
45
|
+
error = 'This sign-in link has already been used.';
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
spent.add(token);
|
|
49
|
+
|
|
50
|
+
// The token is read; keep it out of history and Referers from here on.
|
|
51
|
+
void navigator.dropQuery().catch(() => undefined);
|
|
52
|
+
|
|
53
|
+
// A link can be used once, and a mounted screen verifies it once.
|
|
54
|
+
const result = await auth.client.verifyMagicLink(token);
|
|
55
|
+
|
|
56
|
+
if (!active) return;
|
|
57
|
+
|
|
58
|
+
if (result.error) {
|
|
59
|
+
error = 'Failed to verify token';
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const next = await finishMagicLinkSignIn({
|
|
64
|
+
client: auth.client,
|
|
65
|
+
refreshSession: auth.refreshSession,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!active) return;
|
|
69
|
+
|
|
70
|
+
outcome = next;
|
|
71
|
+
|
|
72
|
+
if (next === 'signed-in') {
|
|
73
|
+
redirect = setTimeout(() => void navigator.toApp(), 900);
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
active = false;
|
|
79
|
+
clearTimeout(redirect);
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<AuthLayout>
|
|
85
|
+
<div class="sa-center">
|
|
86
|
+
<h1 class="sa-heading">Verifying your login</h1>
|
|
87
|
+
|
|
88
|
+
{#if !outcome && !error}
|
|
89
|
+
<div class="sa-spinner" aria-hidden="true"></div>
|
|
90
|
+
<p class="sa-helper-text">Please wait while we securely verify your sign-in link.</p>
|
|
91
|
+
{/if}
|
|
92
|
+
{#if outcome === 'signed-in'}
|
|
93
|
+
<p class="sa-success" role="status">Login verified. Redirecting...</p>
|
|
94
|
+
{/if}
|
|
95
|
+
{#if outcome === 'elsewhere'}
|
|
96
|
+
<p class="sa-success" role="status">
|
|
97
|
+
Login verified. Return to the device where you requested this link to continue.
|
|
98
|
+
</p>
|
|
99
|
+
{/if}
|
|
100
|
+
{#if error}
|
|
101
|
+
<p class="sa-error" role="alert">{error}</p>
|
|
102
|
+
{/if}
|
|
103
|
+
</div>
|
|
104
|
+
</AuthLayout>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Copyright © 2026 Fells Code, LLC
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
See LICENSE file in the project root for full license information
|
|
5
|
+
-->
|
|
6
|
+
<!--
|
|
7
|
+
Checks a one-time code sent by email or text message. The previous screen's
|
|
8
|
+
navigation state says whether this is a sign-in (flow: 'login') or a
|
|
9
|
+
registration.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import {
|
|
13
|
+
formatCountdown,
|
|
14
|
+
OTP_LENGTH,
|
|
15
|
+
OTP_LIFETIME_SECONDS,
|
|
16
|
+
otpResendFailedMessage,
|
|
17
|
+
requestOtp,
|
|
18
|
+
verifyOtp,
|
|
19
|
+
type OtpChannel,
|
|
20
|
+
} from '@seamless-auth/client';
|
|
21
|
+
import { onMount } from 'svelte';
|
|
22
|
+
|
|
23
|
+
import { getAuthNavigator, getSeamlessAuth } from '../context.js';
|
|
24
|
+
import AuthLayout from './AuthLayout.svelte';
|
|
25
|
+
import OtpInput from './OtpInput.svelte';
|
|
26
|
+
|
|
27
|
+
let { channel = 'email' }: { channel?: OtpChannel } = $props();
|
|
28
|
+
|
|
29
|
+
const COPY = {
|
|
30
|
+
email: {
|
|
31
|
+
title: 'Verify Your Email',
|
|
32
|
+
subtitle: 'We sent you a verification email. Enter the code below.',
|
|
33
|
+
label: 'Email Verification Code',
|
|
34
|
+
resend: 'Resend code to email',
|
|
35
|
+
resent: 'Verification email has been resent.',
|
|
36
|
+
},
|
|
37
|
+
phone: {
|
|
38
|
+
title: 'Verify Your Phone Number',
|
|
39
|
+
subtitle: 'Enter the code sent to your phone number.',
|
|
40
|
+
label: 'Phone Verification Code',
|
|
41
|
+
resend: 'Resend code to phone',
|
|
42
|
+
resent: 'Verification SMS has been resent.',
|
|
43
|
+
},
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
const auth = getSeamlessAuth();
|
|
47
|
+
const navigator = getAuthNavigator();
|
|
48
|
+
const flow = navigator.state().flow === 'login' ? 'login' : 'register';
|
|
49
|
+
|
|
50
|
+
const copy = $derived(COPY[channel]);
|
|
51
|
+
let code = $state('');
|
|
52
|
+
let error = $state('');
|
|
53
|
+
let resendMessage = $state('');
|
|
54
|
+
let loading = $state(false);
|
|
55
|
+
let secondsLeft = $state(OTP_LIFETIME_SECONDS);
|
|
56
|
+
|
|
57
|
+
onMount(() => {
|
|
58
|
+
// The registration screen offers passkey enrolment next, which needs this.
|
|
59
|
+
if (channel === 'email') void auth.checkPasskeySupport();
|
|
60
|
+
|
|
61
|
+
const timer = setInterval(() => {
|
|
62
|
+
secondsLeft = Math.max(0, secondsLeft - 1);
|
|
63
|
+
}, 1000);
|
|
64
|
+
return () => clearInterval(timer);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function resend() {
|
|
68
|
+
error = '';
|
|
69
|
+
resendMessage = '';
|
|
70
|
+
|
|
71
|
+
const result = await requestOtp(auth.client, channel, flow);
|
|
72
|
+
|
|
73
|
+
if (result.error) {
|
|
74
|
+
error = otpResendFailedMessage(channel);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
resendMessage = copy.resent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function submit(event: SubmitEvent) {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
if (loading) return;
|
|
84
|
+
error = '';
|
|
85
|
+
loading = true;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const passkeySupported = channel === 'email' ? await auth.checkPasskeySupport() : false;
|
|
89
|
+
|
|
90
|
+
const result = await verifyOtp(
|
|
91
|
+
{ client: auth.client, refreshSession: auth.refreshSession },
|
|
92
|
+
{ channel, flow, code, passkeySupported }
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (result.error !== null) {
|
|
96
|
+
error = result.error;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (result.next === 'register_passkey') await navigator.toScreen('registerPasskey');
|
|
101
|
+
else if (result.next === 'verify_email') await navigator.toScreen('verifyEmailOtp');
|
|
102
|
+
else await navigator.toApp();
|
|
103
|
+
} catch {
|
|
104
|
+
// Backstop for unexpected errors only.
|
|
105
|
+
error = 'Verification failed.';
|
|
106
|
+
} finally {
|
|
107
|
+
loading = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<AuthLayout>
|
|
113
|
+
<h2 class="sa-heading">{copy.title}</h2>
|
|
114
|
+
<p class="sa-subtitle">{copy.subtitle}</p>
|
|
115
|
+
|
|
116
|
+
{#if error}
|
|
117
|
+
<p class="sa-error">{error}</p>
|
|
118
|
+
{/if}
|
|
119
|
+
{#if resendMessage}
|
|
120
|
+
<p class="sa-success">{resendMessage}</p>
|
|
121
|
+
{/if}
|
|
122
|
+
|
|
123
|
+
<form onsubmit={submit}>
|
|
124
|
+
<div>
|
|
125
|
+
<span class="sa-label">
|
|
126
|
+
{copy.label}
|
|
127
|
+
<span class="sa-timer">(code expires in {formatCountdown(secondsLeft)})</span>
|
|
128
|
+
</span>
|
|
129
|
+
<OtpInput bind:value={code} length={OTP_LENGTH} mode={channel === 'email' ? 'text' : 'numeric'} />
|
|
130
|
+
<button type="button" class="sa-link" onclick={resend}>{copy.resend}</button>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<button type="submit" class="sa-button" disabled={loading}>
|
|
134
|
+
{loading ? 'Verifying...' : 'Verify & Continue'}
|
|
135
|
+
</button>
|
|
136
|
+
|
|
137
|
+
<button type="button" class="sa-link" onclick={() => navigator.toScreen('login')}>
|
|
138
|
+
Back to login
|
|
139
|
+
</button>
|
|
140
|
+
</form>
|
|
141
|
+
</AuthLayout>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type OtpChannel } from '@seamless-auth/client';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
channel?: OtpChannel;
|
|
4
|
+
};
|
|
5
|
+
declare const VerifyOtp: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type VerifyOtp = ReturnType<typeof VerifyOtp>;
|
|
7
|
+
export default VerifyOtp;
|