@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,38 @@
|
|
|
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
|
+
The frame every bundled screen renders in. Its stylesheet is unscoped, so an
|
|
8
|
+
application themes the .sa-* classes and --seamless-* properties like any
|
|
9
|
+
other global style.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { onMount, type Snippet } from 'svelte';
|
|
13
|
+
|
|
14
|
+
import { getSeamlessAuth } from '../context.js';
|
|
15
|
+
import styles from '../styles.generated.js';
|
|
16
|
+
|
|
17
|
+
let { card = true, children }: { card?: boolean; children: Snippet } = $props();
|
|
18
|
+
|
|
19
|
+
const STYLE_ID = 'seamless-auth-styles';
|
|
20
|
+
const { styles: options } = getSeamlessAuth();
|
|
21
|
+
|
|
22
|
+
onMount(() => {
|
|
23
|
+
if (!options.inject || document.getElementById(STYLE_ID)) return;
|
|
24
|
+
|
|
25
|
+
const style = document.createElement('style');
|
|
26
|
+
style.id = STYLE_ID;
|
|
27
|
+
if (options.nonce) style.nonce = options.nonce;
|
|
28
|
+
style.textContent = styles;
|
|
29
|
+
// First in <head>, so the application's own stylesheets override it.
|
|
30
|
+
document.head.prepend(style);
|
|
31
|
+
});
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="sa-auth">
|
|
35
|
+
<div class={card ? 'sa-card' : 'sa-center'}>
|
|
36
|
+
{@render children()}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
card?: boolean;
|
|
4
|
+
children: Snippet;
|
|
5
|
+
};
|
|
6
|
+
declare const AuthLayout: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type AuthLayout = ReturnType<typeof AuthLayout>;
|
|
8
|
+
export default AuthLayout;
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
<!-- The other ways to sign in, offered when a passkey is not the way in. -->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import {
|
|
9
|
+
fallbackSignInOptions,
|
|
10
|
+
hasFallbackSignInOption,
|
|
11
|
+
type LoginMethod,
|
|
12
|
+
} from '@seamless-auth/client';
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
identifier,
|
|
16
|
+
loginMethods = null,
|
|
17
|
+
onMagicLink,
|
|
18
|
+
onEmailOtp,
|
|
19
|
+
onPhoneOtp,
|
|
20
|
+
onPasskeyRetry,
|
|
21
|
+
}: {
|
|
22
|
+
identifier: string;
|
|
23
|
+
/** Null while the methods are unknown; the options then stay permissive. */
|
|
24
|
+
loginMethods?: LoginMethod[] | null;
|
|
25
|
+
onMagicLink: () => void;
|
|
26
|
+
onEmailOtp?: () => void;
|
|
27
|
+
onPhoneOtp: () => void;
|
|
28
|
+
onPasskeyRetry?: () => void;
|
|
29
|
+
} = $props();
|
|
30
|
+
|
|
31
|
+
const options = $derived(
|
|
32
|
+
fallbackSignInOptions(identifier, loginMethods, {
|
|
33
|
+
emailOtp: Boolean(onEmailOtp),
|
|
34
|
+
passkeyRetry: Boolean(onPasskeyRetry),
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
{#if hasFallbackSignInOption(options)}
|
|
40
|
+
<div class="sa-fallback">
|
|
41
|
+
<div class="sa-fallback-header">Choose a sign-in method</div>
|
|
42
|
+
<p class="sa-fallback-description">Choose another secure sign-in method.</p>
|
|
43
|
+
|
|
44
|
+
<div class="sa-actions">
|
|
45
|
+
{#if options.magicLink}
|
|
46
|
+
<button type="button" class="sa-action" onclick={onMagicLink}>
|
|
47
|
+
<span class="sa-action-title">Email Magic Link</span>
|
|
48
|
+
<span class="sa-action-subtext">Send a secure sign-in link to your email</span>
|
|
49
|
+
</button>
|
|
50
|
+
{/if}
|
|
51
|
+
{#if options.emailOtp}
|
|
52
|
+
<button type="button" class="sa-action" onclick={onEmailOtp}>
|
|
53
|
+
<span class="sa-action-title">Email Code</span>
|
|
54
|
+
<span class="sa-action-subtext">Receive a one-time code by email</span>
|
|
55
|
+
</button>
|
|
56
|
+
{/if}
|
|
57
|
+
{#if options.phoneOtp}
|
|
58
|
+
<button type="button" class="sa-action" onclick={onPhoneOtp}>
|
|
59
|
+
<span class="sa-action-title">Text Message Code</span>
|
|
60
|
+
<span class="sa-action-subtext">Receive a one-time code via SMS</span>
|
|
61
|
+
</button>
|
|
62
|
+
{/if}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{#if options.passkeyRetry}
|
|
66
|
+
<button type="button" class="sa-link" onclick={onPasskeyRetry}>Try passkey anyway</button>
|
|
67
|
+
{/if}
|
|
68
|
+
</div>
|
|
69
|
+
{/if}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type LoginMethod } from '@seamless-auth/client';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
identifier: string;
|
|
4
|
+
/** Null while the methods are unknown; the options then stay permissive. */
|
|
5
|
+
loginMethods?: LoginMethod[] | null;
|
|
6
|
+
onMagicLink: () => void;
|
|
7
|
+
onEmailOtp?: () => void;
|
|
8
|
+
onPhoneOtp: () => void;
|
|
9
|
+
onPasskeyRetry?: () => void;
|
|
10
|
+
};
|
|
11
|
+
declare const FallbackOptions: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type FallbackOptions = ReturnType<typeof FallbackOptions>;
|
|
13
|
+
export default FallbackOptions;
|
|
@@ -0,0 +1,253 @@
|
|
|
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 an email or phone number, or create an account with an email. -->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import {
|
|
9
|
+
beginSignIn,
|
|
10
|
+
isValidEmail,
|
|
11
|
+
isValidPhoneNumber,
|
|
12
|
+
PASSKEY_SIGN_IN_FAILED,
|
|
13
|
+
registerWithEmail,
|
|
14
|
+
type LoginMethod,
|
|
15
|
+
} from '@seamless-auth/client';
|
|
16
|
+
import { onMount } from 'svelte';
|
|
17
|
+
|
|
18
|
+
import { getAuthNavigator, getSeamlessAuth } from '../context.js';
|
|
19
|
+
import AuthLayout from './AuthLayout.svelte';
|
|
20
|
+
import FallbackOptions from './FallbackOptions.svelte';
|
|
21
|
+
import OAuthProviderButtons from './OAuthProviderButtons.svelte';
|
|
22
|
+
|
|
23
|
+
const auth = getSeamlessAuth();
|
|
24
|
+
const navigator = getAuthNavigator();
|
|
25
|
+
|
|
26
|
+
// Opens on Sign In for a browser that has signed in before.
|
|
27
|
+
let chosenMode = $state<'login' | 'register' | null>(null);
|
|
28
|
+
const mode = $derived(chosenMode ?? (auth.hasSignedInBefore ? 'login' : 'register'));
|
|
29
|
+
|
|
30
|
+
let identifier = $state('');
|
|
31
|
+
let email = $state('');
|
|
32
|
+
let identifierError = $state('');
|
|
33
|
+
let emailError = $state('');
|
|
34
|
+
let formError = $state('');
|
|
35
|
+
let showFallbackOptions = $state(false);
|
|
36
|
+
let loginMethods = $state.raw<LoginMethod[] | null>(null);
|
|
37
|
+
let submitting = $state(false);
|
|
38
|
+
|
|
39
|
+
const canSubmit = $derived(
|
|
40
|
+
mode === 'login'
|
|
41
|
+
? isValidEmail(identifier) || isValidPhoneNumber(identifier)
|
|
42
|
+
: // Registration only needs a valid email. A phone can be added later.
|
|
43
|
+
isValidEmail(email)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// A disabled button is skipped by screen readers and explains nothing to
|
|
47
|
+
// anyone else, so the reason it is refusing lives in a live region instead.
|
|
48
|
+
const submitHint = $derived.by(() => {
|
|
49
|
+
if (mode === 'login') {
|
|
50
|
+
if (!identifier) return 'Enter your email or phone number to continue.';
|
|
51
|
+
return canSubmit
|
|
52
|
+
? 'Ready to continue.'
|
|
53
|
+
: 'This does not look like a complete email or phone number yet.';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!email) return 'Enter your email address to continue.';
|
|
57
|
+
return canSubmit
|
|
58
|
+
? 'Ready to continue.'
|
|
59
|
+
: 'This does not look like a complete email address yet.';
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
onMount(() => {
|
|
63
|
+
void auth.loadLoginMethods();
|
|
64
|
+
void auth.checkPasskeySupport();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function validateIdentifier() {
|
|
68
|
+
if (!identifier) return;
|
|
69
|
+
identifierError =
|
|
70
|
+
isValidEmail(identifier) || isValidPhoneNumber(identifier)
|
|
71
|
+
? ''
|
|
72
|
+
: 'Please enter a valid email or phone number';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateEmail() {
|
|
76
|
+
if (!email) return;
|
|
77
|
+
emailError = isValidEmail(email) ? '' : 'Please enter a valid email';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function submit(event: SubmitEvent) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
if (!canSubmit || submitting) return;
|
|
83
|
+
|
|
84
|
+
formError = '';
|
|
85
|
+
showFallbackOptions = false;
|
|
86
|
+
submitting = true;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
if (mode === 'register') {
|
|
90
|
+
const { error } = await registerWithEmail(auth.client, email);
|
|
91
|
+
|
|
92
|
+
if (error) {
|
|
93
|
+
formError = error;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await navigator.toScreen('verifyEmailOtp');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Both are cached, so awaiting them costs nothing once loaded and removes
|
|
102
|
+
// the race where a fast submit saw passkeys as unsupported.
|
|
103
|
+
const [passkeySupported, configuredMethods] = await Promise.all([
|
|
104
|
+
auth.checkPasskeySupport(),
|
|
105
|
+
auth.loadLoginMethods(),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const step = await beginSignIn(auth, { identifier, passkeySupported, configuredMethods });
|
|
109
|
+
|
|
110
|
+
if (step.kind === 'error') {
|
|
111
|
+
formError = step.message;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (step.kind === 'signed_in') {
|
|
116
|
+
await navigator.toApp();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
loginMethods = step.loginMethods;
|
|
121
|
+
showFallbackOptions = true;
|
|
122
|
+
|
|
123
|
+
if (step.passkeyFailed) formError = PASSKEY_SIGN_IN_FAILED;
|
|
124
|
+
} catch {
|
|
125
|
+
// Backstop for unexpected errors only. The client reports request
|
|
126
|
+
// failures through `error`, not by throwing.
|
|
127
|
+
formError = 'Failed to continue sign-in. Please try again.';
|
|
128
|
+
} finally {
|
|
129
|
+
submitting = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function retryPasskey() {
|
|
134
|
+
formError = '';
|
|
135
|
+
const { error } = await auth.handlePasskeyLogin();
|
|
136
|
+
|
|
137
|
+
if (error) {
|
|
138
|
+
formError = PASSKEY_SIGN_IN_FAILED;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await navigator.toApp();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function sendMagicLink() {
|
|
146
|
+
const { error } = await auth.client.requestMagicLink();
|
|
147
|
+
|
|
148
|
+
if (error) {
|
|
149
|
+
formError = 'Failed to send magic link.';
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await navigator.toScreen('magicLinkSent', { identifier });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function sendEmailOtp() {
|
|
157
|
+
const { error } = await auth.client.requestLoginEmailOtp();
|
|
158
|
+
|
|
159
|
+
if (error) {
|
|
160
|
+
formError = 'Failed to send email code.';
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await navigator.toScreen('verifyEmailOtp', { flow: 'login' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function sendPhoneOtp() {
|
|
168
|
+
const { error } = await auth.client.requestLoginPhoneOtp();
|
|
169
|
+
|
|
170
|
+
if (error) {
|
|
171
|
+
formError = 'Failed to send OTP.';
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await navigator.toScreen('verifyPhoneOtp', { flow: 'login' });
|
|
176
|
+
}
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<AuthLayout>
|
|
180
|
+
<h2 class="sa-heading">{mode === 'login' ? 'Sign In' : 'Create Account'}</h2>
|
|
181
|
+
|
|
182
|
+
<form onsubmit={submit}>
|
|
183
|
+
{#if mode === 'login'}
|
|
184
|
+
<div class="sa-input-group">
|
|
185
|
+
<label for="identifier" class="sa-label">Email Address / Phone Number</label>
|
|
186
|
+
<input
|
|
187
|
+
id="identifier"
|
|
188
|
+
type="text"
|
|
189
|
+
class="sa-input"
|
|
190
|
+
autocomplete="off"
|
|
191
|
+
placeholder="Email or Phone Number"
|
|
192
|
+
required
|
|
193
|
+
bind:value={identifier}
|
|
194
|
+
onblur={validateIdentifier}
|
|
195
|
+
/>
|
|
196
|
+
<p class="sa-helper-text">Phone numbers must include a country code e.g. +1</p>
|
|
197
|
+
|
|
198
|
+
{#if showFallbackOptions}
|
|
199
|
+
<FallbackOptions
|
|
200
|
+
{identifier}
|
|
201
|
+
{loginMethods}
|
|
202
|
+
onMagicLink={sendMagicLink}
|
|
203
|
+
onEmailOtp={sendEmailOtp}
|
|
204
|
+
onPhoneOtp={sendPhoneOtp}
|
|
205
|
+
onPasskeyRetry={auth.passkeySupported ? retryPasskey : undefined}
|
|
206
|
+
/>
|
|
207
|
+
{/if}
|
|
208
|
+
|
|
209
|
+
{#if identifierError}
|
|
210
|
+
<p class="sa-error">{identifierError}</p>
|
|
211
|
+
{/if}
|
|
212
|
+
</div>
|
|
213
|
+
{:else}
|
|
214
|
+
<div class="sa-input-group">
|
|
215
|
+
<label for="email" class="sa-label">Email Address</label>
|
|
216
|
+
<input
|
|
217
|
+
id="email"
|
|
218
|
+
type="email"
|
|
219
|
+
class="sa-input"
|
|
220
|
+
autocomplete="off"
|
|
221
|
+
required
|
|
222
|
+
bind:value={email}
|
|
223
|
+
onblur={validateEmail}
|
|
224
|
+
/>
|
|
225
|
+
{#if emailError}
|
|
226
|
+
<p class="sa-error">{emailError}</p>
|
|
227
|
+
{/if}
|
|
228
|
+
</div>
|
|
229
|
+
{/if}
|
|
230
|
+
|
|
231
|
+
<button
|
|
232
|
+
type="submit"
|
|
233
|
+
class="sa-button"
|
|
234
|
+
disabled={!canSubmit || submitting}
|
|
235
|
+
aria-describedby="seamless-submit-hint"
|
|
236
|
+
>
|
|
237
|
+
{mode === 'login' ? 'Login' : 'Register'}
|
|
238
|
+
</button>
|
|
239
|
+
<p id="seamless-submit-hint" role="status" class="sa-submit-hint">{submitHint}</p>
|
|
240
|
+
{#if formError}
|
|
241
|
+
<p class="sa-error">{formError}</p>
|
|
242
|
+
{/if}
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
class="sa-link"
|
|
246
|
+
onclick={() => (chosenMode = mode === 'login' ? 'register' : 'login')}
|
|
247
|
+
>
|
|
248
|
+
{mode === 'login' ? "Don't have an account? Create one" : 'Already have an account? Sign in'}
|
|
249
|
+
</button>
|
|
250
|
+
</form>
|
|
251
|
+
|
|
252
|
+
<OAuthProviderButtons />
|
|
253
|
+
</AuthLayout>
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
Waits for an emailed link to be used, then signs this tab in. A link opened in
|
|
8
|
+
another tab of this browser reports back at once; one opened on another device
|
|
9
|
+
is picked up by polling.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { MAGIC_LINK_RESEND_COOLDOWN_SECONDS, watchMagicLink } from '@seamless-auth/client';
|
|
13
|
+
import { onMount } from 'svelte';
|
|
14
|
+
|
|
15
|
+
import { getAuthNavigator, getSeamlessAuth } from '../context.js';
|
|
16
|
+
import AuthLayout from './AuthLayout.svelte';
|
|
17
|
+
|
|
18
|
+
const auth = getSeamlessAuth();
|
|
19
|
+
const navigator = getAuthNavigator();
|
|
20
|
+
const identifier = navigator.state().identifier as string | undefined;
|
|
21
|
+
let cooldown = $state(MAGIC_LINK_RESEND_COOLDOWN_SECONDS);
|
|
22
|
+
|
|
23
|
+
// Browser only: a server render must not poll or open a channel.
|
|
24
|
+
onMount(() => {
|
|
25
|
+
const timer = setInterval(() => {
|
|
26
|
+
cooldown = Math.max(0, cooldown - 1);
|
|
27
|
+
}, 1000);
|
|
28
|
+
|
|
29
|
+
const stop = watchMagicLink({
|
|
30
|
+
client: auth.client,
|
|
31
|
+
refreshSession: auth.refreshSession,
|
|
32
|
+
onSignedIn: () => void navigator.toApp(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
clearInterval(timer);
|
|
37
|
+
stop();
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
async function resend() {
|
|
42
|
+
if (cooldown > 0) return;
|
|
43
|
+
await auth.client.requestMagicLink();
|
|
44
|
+
cooldown = MAGIC_LINK_RESEND_COOLDOWN_SECONDS;
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<AuthLayout>
|
|
49
|
+
<div class="sa-center">
|
|
50
|
+
<h2 class="sa-heading">Check your email</h2>
|
|
51
|
+
<p class="sa-description">
|
|
52
|
+
If an account exists for this address, we sent a secure sign-in link.
|
|
53
|
+
</p>
|
|
54
|
+
{#if identifier}
|
|
55
|
+
<div class="sa-identifier">{identifier}</div>
|
|
56
|
+
{/if}
|
|
57
|
+
<p class="sa-helper-text">Open the email and click the link to finish signing in.</p>
|
|
58
|
+
<p class="sa-helper-text">
|
|
59
|
+
Didn't receive anything? Check your spam folder or try creating a new account.
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="sa-actions">
|
|
64
|
+
<button type="button" class="sa-secondary" disabled={cooldown > 0} onclick={resend}>
|
|
65
|
+
Resend link
|
|
66
|
+
</button>
|
|
67
|
+
{#if cooldown > 0}
|
|
68
|
+
<div class="sa-helper-text sa-center">Available in {cooldown}s</div>
|
|
69
|
+
{/if}
|
|
70
|
+
<button type="button" class="sa-link" onclick={() => navigator.toScreen('login')}>
|
|
71
|
+
Change email or phone
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
</AuthLayout>
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
<!-- The OAuth redirect URI. Finishes a provider sign-in. -->
|
|
7
|
+
<script module lang="ts">
|
|
8
|
+
// Codes this page has already spent. A code can be exchanged once, and a screen
|
|
9
|
+
// that mounts again must not send it a second time.
|
|
10
|
+
const spent = new Set<string>();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<script lang="ts">
|
|
14
|
+
import { completeOAuthCallback } from '@seamless-auth/client';
|
|
15
|
+
import { onMount } from 'svelte';
|
|
16
|
+
|
|
17
|
+
import { getAuthNavigator, getSeamlessAuth } from '../context.js';
|
|
18
|
+
import AuthLayout from './AuthLayout.svelte';
|
|
19
|
+
|
|
20
|
+
const auth = getSeamlessAuth();
|
|
21
|
+
const navigator = getAuthNavigator();
|
|
22
|
+
let error = $state('');
|
|
23
|
+
|
|
24
|
+
// Browser only: the code is single use, and a server render has neither the
|
|
25
|
+
// browser's cookies nor a window.
|
|
26
|
+
onMount(() => {
|
|
27
|
+
void (async () => {
|
|
28
|
+
const params = new URLSearchParams();
|
|
29
|
+
for (const name of ['code', 'state']) {
|
|
30
|
+
const value = navigator.query(name);
|
|
31
|
+
if (value !== null) params.set(name, value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const code = params.get('code');
|
|
35
|
+
if (code !== null && spent.has(code)) {
|
|
36
|
+
error = 'This sign-in link has already been used.';
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (code !== null) spent.add(code);
|
|
40
|
+
|
|
41
|
+
// The code and state are read; keep them out of history and Referers.
|
|
42
|
+
void navigator.dropQuery().catch(() => undefined);
|
|
43
|
+
|
|
44
|
+
const outcome = await completeOAuthCallback(auth, params, window.location.origin);
|
|
45
|
+
|
|
46
|
+
if (outcome.kind === 'error') {
|
|
47
|
+
error = outcome.message;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (outcome.kind === 'enroll_passkey') {
|
|
52
|
+
await navigator.toScreen('registerPasskey', { returnTo: outcome.returnTo });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await navigator.toLocation(outcome.destination);
|
|
57
|
+
})();
|
|
58
|
+
});
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<AuthLayout card={false}>
|
|
62
|
+
<h2 class="sa-heading">{error ? 'Sign-in failed' : 'Completing sign-in...'}</h2>
|
|
63
|
+
{#if error}
|
|
64
|
+
<p>{error}</p>
|
|
65
|
+
<button type="button" class="sa-link" onclick={() => navigator.toScreen('login')}>
|
|
66
|
+
Back to login
|
|
67
|
+
</button>
|
|
68
|
+
{/if}
|
|
69
|
+
</AuthLayout>
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
<!-- A button per OAuth provider the instance has enabled. Renders nothing without one. -->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { startOAuthSignIn, type OAuthProvider } from '@seamless-auth/client';
|
|
9
|
+
import { onMount } from 'svelte';
|
|
10
|
+
|
|
11
|
+
import { getAuthNavigator, getSeamlessAuth } from '../context.js';
|
|
12
|
+
|
|
13
|
+
/** The redirect URI to register with providers. Defaults to the bundled callback screen. */
|
|
14
|
+
let { redirectUri }: { redirectUri?: string } = $props();
|
|
15
|
+
|
|
16
|
+
const auth = getSeamlessAuth();
|
|
17
|
+
const navigator = getAuthNavigator();
|
|
18
|
+
let providers = $state.raw<OAuthProvider[]>([]);
|
|
19
|
+
let error = $state('');
|
|
20
|
+
|
|
21
|
+
onMount(async () => {
|
|
22
|
+
const { data } = await auth.listOAuthProviders();
|
|
23
|
+
providers = data?.providers ?? [];
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function select(providerId: string) {
|
|
27
|
+
error = '';
|
|
28
|
+
|
|
29
|
+
const result = await startOAuthSignIn(
|
|
30
|
+
{ actions: auth, oauthRedirect: auth.ports.oauthRedirect },
|
|
31
|
+
{ providerId, redirectUri: redirectUri ?? navigator.absoluteUrl('oauthCallback') }
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (result.error) error = result.error;
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
{#if providers.length > 0}
|
|
39
|
+
<div class="sa-actions">
|
|
40
|
+
{#each providers as provider (provider.id)}
|
|
41
|
+
<button type="button" class="sa-action" onclick={() => select(provider.id)}>
|
|
42
|
+
<span class="sa-action-title">Continue with {provider.name}</span>
|
|
43
|
+
</button>
|
|
44
|
+
{/each}
|
|
45
|
+
{#if error}
|
|
46
|
+
<p class="sa-error">{error}</p>
|
|
47
|
+
{/if}
|
|
48
|
+
</div>
|
|
49
|
+
{/if}
|