@sentropic/auth-ui 0.2.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/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/contracts.d.ts +7 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +7 -0
- package/dist/contracts.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +12 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.d.ts +5 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +180 -0
- package/dist/labels.js.map +1 -0
- package/dist/transport-fetch.d.ts +25 -0
- package/dist/transport-fetch.d.ts.map +1 -0
- package/dist/transport-fetch.js +98 -0
- package/dist/transport-fetch.js.map +1 -0
- package/dist/transport-types.d.ts +77 -0
- package/dist/transport-types.d.ts.map +1 -0
- package/dist/transport-types.js +2 -0
- package/dist/transport-types.js.map +1 -0
- package/dist/transport.d.ts +3 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +29 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +137 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/webauthn.d.ts +26 -0
- package/dist/webauthn.d.ts.map +1 -0
- package/dist/webauthn.js +76 -0
- package/dist/webauthn.js.map +1 -0
- package/package.json +86 -0
- package/src/components/AuthDevicePair.svelte +173 -0
- package/src/components/AuthDevicePair.svelte.d.ts +17 -0
- package/src/components/AuthDevices.svelte +313 -0
- package/src/components/AuthDevices.svelte.d.ts +18 -0
- package/src/components/AuthLogin.svelte +222 -0
- package/src/components/AuthLogin.svelte.d.ts +18 -0
- package/src/components/AuthMagicLinkVerify.svelte +165 -0
- package/src/components/AuthMagicLinkVerify.svelte.d.ts +20 -0
- package/src/components/AuthRegister.svelte +394 -0
- package/src/components/AuthRegister.svelte.d.ts +25 -0
- package/src/contracts.ts +6 -0
- package/src/errors.ts +18 -0
- package/src/index.ts +2 -0
- package/src/labels.ts +186 -0
- package/src/transport-fetch.ts +170 -0
- package/src/transport-types.ts +105 -0
- package/src/transport.ts +33 -0
- package/src/types.ts +153 -0
- package/src/webauthn.ts +133 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, tick } from 'svelte';
|
|
3
|
+
import {
|
|
4
|
+
createDefaultAuthUiLabels,
|
|
5
|
+
normalizeAuthEmail,
|
|
6
|
+
type AuthUiError,
|
|
7
|
+
type AuthUiLabels,
|
|
8
|
+
type AuthUiSession,
|
|
9
|
+
type AuthUiTransport,
|
|
10
|
+
} from '../contracts.js';
|
|
11
|
+
import {
|
|
12
|
+
isWebAuthnSupported,
|
|
13
|
+
startPasskeyRegistration,
|
|
14
|
+
getWebAuthnErrorMessage,
|
|
15
|
+
} from '../webauthn.js';
|
|
16
|
+
|
|
17
|
+
type Step = 'email' | 'code' | 'webauthn' | 'success';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
transport: AuthUiTransport;
|
|
21
|
+
labels?: Partial<AuthUiLabels>;
|
|
22
|
+
onRegistered: (session: AuthUiSession) => void | Promise<void>;
|
|
23
|
+
onError?: (error: AuthUiError) => void;
|
|
24
|
+
/**
|
|
25
|
+
* When true, the component skips the email + code steps and renders only
|
|
26
|
+
* the WebAuthn registration. Hosts must then supply `presetEmail`
|
|
27
|
+
* and `presetVerificationToken` (obtained through their own pre-auth flow).
|
|
28
|
+
*/
|
|
29
|
+
skipEmailVerification?: boolean;
|
|
30
|
+
presetEmail?: string;
|
|
31
|
+
presetVerificationToken?: string;
|
|
32
|
+
/** Optional default device name passed to the passkey registration. */
|
|
33
|
+
deviceName?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let {
|
|
37
|
+
transport,
|
|
38
|
+
labels,
|
|
39
|
+
onRegistered,
|
|
40
|
+
onError,
|
|
41
|
+
skipEmailVerification = false,
|
|
42
|
+
presetEmail,
|
|
43
|
+
presetVerificationToken,
|
|
44
|
+
deviceName,
|
|
45
|
+
}: Props = $props();
|
|
46
|
+
|
|
47
|
+
const resolvedLabels = $derived(createDefaultAuthUiLabels(labels ?? {}));
|
|
48
|
+
|
|
49
|
+
let email = $state(presetEmail ?? '');
|
|
50
|
+
let codeDigits = $state<string[]>(['', '', '', '', '', '']);
|
|
51
|
+
let loading = $state(false);
|
|
52
|
+
let error = $state('');
|
|
53
|
+
let success = $state('');
|
|
54
|
+
let webauthnSupported = $state(false);
|
|
55
|
+
let step = $state<Step>(skipEmailVerification ? 'webauthn' : 'email');
|
|
56
|
+
let verificationToken = $state(presetVerificationToken ?? '');
|
|
57
|
+
let userId = $state('');
|
|
58
|
+
|
|
59
|
+
const codeString = $derived(codeDigits.join(''));
|
|
60
|
+
|
|
61
|
+
onMount(() => {
|
|
62
|
+
webauthnSupported = isWebAuthnSupported();
|
|
63
|
+
if (!webauthnSupported) {
|
|
64
|
+
error = resolvedLabels.registerUnsupportedBrowser;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function isValidEmail(value: string): boolean {
|
|
69
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function focusCodeInput(index: number): Promise<void> {
|
|
73
|
+
await tick();
|
|
74
|
+
const target = document.getElementById(`auth-ui-code-${index}`) as HTMLInputElement | null;
|
|
75
|
+
target?.focus();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reportError(err: AuthUiError, fallback: string): void {
|
|
79
|
+
error = err.message || fallback;
|
|
80
|
+
onError?.(err);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function handleRequestCode(event?: SubmitEvent): Promise<void> {
|
|
84
|
+
event?.preventDefault();
|
|
85
|
+
const normalized = normalizeAuthEmail(email);
|
|
86
|
+
if (!normalized || !isValidEmail(normalized)) {
|
|
87
|
+
error = resolvedLabels.registerErrorInvalidEmail;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
email = normalized;
|
|
91
|
+
loading = true;
|
|
92
|
+
error = '';
|
|
93
|
+
success = '';
|
|
94
|
+
|
|
95
|
+
const result = await transport.requestEmailCode({ email: normalized });
|
|
96
|
+
loading = false;
|
|
97
|
+
if (!result.ok) {
|
|
98
|
+
reportError(result.error, resolvedLabels.registerErrorSendCode);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
success = resolvedLabels.registerSuccessCodeSent;
|
|
102
|
+
step = 'code';
|
|
103
|
+
await focusCodeInput(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function updateDigit(index: number, value: string): void {
|
|
107
|
+
const digit = value.replace(/[^0-9]/g, '').slice(0, 1);
|
|
108
|
+
codeDigits[index] = digit;
|
|
109
|
+
if (digit && index < 5) {
|
|
110
|
+
void focusCodeInput(index + 1);
|
|
111
|
+
}
|
|
112
|
+
if (codeString.length === 6 && !loading) {
|
|
113
|
+
setTimeout(() => void handleCodeSubmit(), 100);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleCodeKeydown(index: number, event: KeyboardEvent): void {
|
|
118
|
+
if (event.key === 'Backspace' && !codeDigits[index] && index > 0) {
|
|
119
|
+
codeDigits[index - 1] = '';
|
|
120
|
+
void focusCodeInput(index - 1);
|
|
121
|
+
} else if (event.key === 'ArrowLeft' && index > 0) {
|
|
122
|
+
void focusCodeInput(index - 1);
|
|
123
|
+
} else if (event.key === 'ArrowRight' && index < 5) {
|
|
124
|
+
void focusCodeInput(index + 1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleCodePaste(event: ClipboardEvent): void {
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
const pasted = event.clipboardData?.getData('text') ?? '';
|
|
131
|
+
const digits = pasted.replace(/[^0-9]/g, '').slice(0, 6).split('');
|
|
132
|
+
digits.forEach((digit, index) => {
|
|
133
|
+
codeDigits[index] = digit;
|
|
134
|
+
});
|
|
135
|
+
if (digits.length === 6 && !loading) {
|
|
136
|
+
setTimeout(() => void handleCodeSubmit(), 100);
|
|
137
|
+
} else {
|
|
138
|
+
void focusCodeInput(Math.min(digits.length, 5));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function handleCodeSubmit(event?: SubmitEvent): Promise<void> {
|
|
143
|
+
event?.preventDefault();
|
|
144
|
+
if (loading || codeString.length !== 6) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
loading = true;
|
|
148
|
+
error = '';
|
|
149
|
+
success = '';
|
|
150
|
+
|
|
151
|
+
const result = await transport.verifyEmailCode({
|
|
152
|
+
email: normalizeAuthEmail(email),
|
|
153
|
+
code: codeString,
|
|
154
|
+
});
|
|
155
|
+
loading = false;
|
|
156
|
+
if (!result.ok) {
|
|
157
|
+
reportError(result.error, result.error.message);
|
|
158
|
+
codeDigits = ['', '', '', '', '', ''];
|
|
159
|
+
await focusCodeInput(0);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
verificationToken = result.value.verificationToken;
|
|
163
|
+
step = 'webauthn';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function handleWebAuthnRegister(): Promise<void> {
|
|
167
|
+
loading = true;
|
|
168
|
+
error = '';
|
|
169
|
+
success = '';
|
|
170
|
+
|
|
171
|
+
const optionsResult = await transport.createPasskeyRegistrationOptions({
|
|
172
|
+
email: normalizeAuthEmail(email),
|
|
173
|
+
verificationToken,
|
|
174
|
+
deviceName,
|
|
175
|
+
});
|
|
176
|
+
if (!optionsResult.ok) {
|
|
177
|
+
reportError(optionsResult.error, optionsResult.error.message);
|
|
178
|
+
loading = false;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
userId = optionsResult.value.userId;
|
|
182
|
+
|
|
183
|
+
let credential;
|
|
184
|
+
try {
|
|
185
|
+
credential = await startPasskeyRegistration(optionsResult.value.options);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const authError = err as AuthUiError;
|
|
188
|
+
error = getWebAuthnErrorMessage(authError, resolvedLabels);
|
|
189
|
+
onError?.(authError);
|
|
190
|
+
loading = false;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const verifyResult = await transport.verifyPasskeyRegistration({
|
|
195
|
+
email: normalizeAuthEmail(email),
|
|
196
|
+
verificationToken,
|
|
197
|
+
userId,
|
|
198
|
+
credential,
|
|
199
|
+
deviceName,
|
|
200
|
+
});
|
|
201
|
+
loading = false;
|
|
202
|
+
if (!verifyResult.ok) {
|
|
203
|
+
reportError(verifyResult.error, verifyResult.error.message);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
step = 'success';
|
|
207
|
+
success = resolvedLabels.registerSuccessRedirecting;
|
|
208
|
+
await onRegistered(verifyResult.value);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function backToEmail(): void {
|
|
212
|
+
step = 'email';
|
|
213
|
+
codeDigits = ['', '', '', '', '', ''];
|
|
214
|
+
}
|
|
215
|
+
</script>
|
|
216
|
+
|
|
217
|
+
<div class="auth-ui-register">
|
|
218
|
+
<header class="auth-ui-header">
|
|
219
|
+
<h2 class="auth-ui-title">{resolvedLabels.registerTitle}</h2>
|
|
220
|
+
<p class="auth-ui-subtitle">{resolvedLabels.registerSubtitle}</p>
|
|
221
|
+
</header>
|
|
222
|
+
|
|
223
|
+
{#if !webauthnSupported}
|
|
224
|
+
<div class="auth-ui-alert auth-ui-alert--error" role="alert">
|
|
225
|
+
<h3 class="auth-ui-alert__title">{resolvedLabels.registerUnsupportedBrowserTitle}</h3>
|
|
226
|
+
<p>{error}</p>
|
|
227
|
+
</div>
|
|
228
|
+
{:else if step === 'email'}
|
|
229
|
+
<form class="auth-ui-form" onsubmit={handleRequestCode}>
|
|
230
|
+
<div class="auth-ui-field">
|
|
231
|
+
<label for="auth-ui-register-email" class="auth-ui-label">{resolvedLabels.registerEmailLabel}</label>
|
|
232
|
+
<input
|
|
233
|
+
id="auth-ui-register-email"
|
|
234
|
+
type="email"
|
|
235
|
+
required
|
|
236
|
+
bind:value={email}
|
|
237
|
+
disabled={loading}
|
|
238
|
+
placeholder={resolvedLabels.emailPlaceholder}
|
|
239
|
+
class="auth-ui-input"
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
{#if success}
|
|
243
|
+
<div class="auth-ui-alert auth-ui-alert--success" role="status">{success}</div>
|
|
244
|
+
{/if}
|
|
245
|
+
<button
|
|
246
|
+
type="submit"
|
|
247
|
+
disabled={loading || !email.trim() || !isValidEmail(email.trim())}
|
|
248
|
+
class="auth-ui-button auth-ui-button--primary"
|
|
249
|
+
>
|
|
250
|
+
{loading ? resolvedLabels.registerSendingCode : resolvedLabels.registerGetCode}
|
|
251
|
+
</button>
|
|
252
|
+
{#if error}
|
|
253
|
+
<div class="auth-ui-alert auth-ui-alert--error" role="alert">{error}</div>
|
|
254
|
+
{/if}
|
|
255
|
+
<div class="auth-ui-actions">
|
|
256
|
+
<slot name="login-link">
|
|
257
|
+
<span class="auth-ui-link">{resolvedLabels.registerAlreadyHaveAccount}</span>
|
|
258
|
+
</slot>
|
|
259
|
+
</div>
|
|
260
|
+
</form>
|
|
261
|
+
{:else if step === 'code'}
|
|
262
|
+
<form class="auth-ui-form" onsubmit={handleCodeSubmit}>
|
|
263
|
+
<div class="auth-ui-field">
|
|
264
|
+
<label for="auth-ui-register-email-readonly" class="auth-ui-label">{resolvedLabels.registerEmailLabel}</label>
|
|
265
|
+
<input
|
|
266
|
+
id="auth-ui-register-email-readonly"
|
|
267
|
+
type="email"
|
|
268
|
+
value={email}
|
|
269
|
+
disabled
|
|
270
|
+
class="auth-ui-input auth-ui-input--disabled"
|
|
271
|
+
/>
|
|
272
|
+
</div>
|
|
273
|
+
<div class="auth-ui-field">
|
|
274
|
+
<label class="auth-ui-label" for="auth-ui-code-0">{resolvedLabels.registerCodeLabel}</label>
|
|
275
|
+
<div class="auth-ui-code-row" onpaste={handleCodePaste}>
|
|
276
|
+
{#each codeDigits as _, index (index)}
|
|
277
|
+
<input
|
|
278
|
+
id={`auth-ui-code-${index}`}
|
|
279
|
+
type="text"
|
|
280
|
+
inputmode="numeric"
|
|
281
|
+
maxlength="1"
|
|
282
|
+
disabled={loading}
|
|
283
|
+
bind:value={codeDigits[index]}
|
|
284
|
+
oninput={(e) => updateDigit(index, e.currentTarget.value)}
|
|
285
|
+
onkeydown={(e) => handleCodeKeydown(index, e)}
|
|
286
|
+
autocomplete="off"
|
|
287
|
+
class="auth-ui-code-input"
|
|
288
|
+
/>
|
|
289
|
+
{/each}
|
|
290
|
+
</div>
|
|
291
|
+
<p class="auth-ui-help">{resolvedLabels.registerCodeHelp}</p>
|
|
292
|
+
</div>
|
|
293
|
+
{#if error}
|
|
294
|
+
<div class="auth-ui-alert auth-ui-alert--error" role="alert">{error}</div>
|
|
295
|
+
{/if}
|
|
296
|
+
<button
|
|
297
|
+
type="submit"
|
|
298
|
+
disabled={loading || codeString.length !== 6}
|
|
299
|
+
class="auth-ui-button auth-ui-button--primary"
|
|
300
|
+
>
|
|
301
|
+
{loading ? resolvedLabels.registerVerifyingCode : resolvedLabels.registerVerifyCode}
|
|
302
|
+
</button>
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
class="auth-ui-button auth-ui-button--ghost"
|
|
306
|
+
onclick={backToEmail}
|
|
307
|
+
>
|
|
308
|
+
{resolvedLabels.registerChangeEmail}
|
|
309
|
+
</button>
|
|
310
|
+
</form>
|
|
311
|
+
{:else if step === 'webauthn'}
|
|
312
|
+
<div class="auth-ui-section">
|
|
313
|
+
<div class="auth-ui-alert auth-ui-alert--info" role="status">
|
|
314
|
+
<h3 class="auth-ui-alert__title">{resolvedLabels.registerCodeVerifiedTitle}</h3>
|
|
315
|
+
<p>{resolvedLabels.registerDeviceNow}</p>
|
|
316
|
+
</div>
|
|
317
|
+
{#if error}
|
|
318
|
+
<div class="auth-ui-alert auth-ui-alert--error" role="alert">{error}</div>
|
|
319
|
+
{/if}
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
disabled={loading}
|
|
323
|
+
onclick={handleWebAuthnRegister}
|
|
324
|
+
class="auth-ui-button auth-ui-button--primary"
|
|
325
|
+
>
|
|
326
|
+
{loading ? resolvedLabels.registerRegistering : resolvedLabels.registerPasskeyButton}
|
|
327
|
+
</button>
|
|
328
|
+
</div>
|
|
329
|
+
{:else if step === 'success'}
|
|
330
|
+
<div class="auth-ui-alert auth-ui-alert--success" role="status">
|
|
331
|
+
<h3 class="auth-ui-alert__title">{resolvedLabels.registerSuccessTitle}</h3>
|
|
332
|
+
<p>{success}</p>
|
|
333
|
+
</div>
|
|
334
|
+
{/if}
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<style>
|
|
338
|
+
.auth-ui-register {
|
|
339
|
+
display: flex; flex-direction: column; gap: 1.5rem;
|
|
340
|
+
max-width: 28rem; margin: 0 auto; padding: 2rem 1rem;
|
|
341
|
+
font-family: var(--auth-font-family, system-ui, -apple-system, sans-serif);
|
|
342
|
+
color: var(--auth-text, #111827);
|
|
343
|
+
}
|
|
344
|
+
.auth-ui-header { text-align: center; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
345
|
+
.auth-ui-title { margin: 0; font-size: 1.5rem; font-weight: 700; }
|
|
346
|
+
.auth-ui-subtitle { margin: 0; font-size: 0.875rem; color: var(--auth-muted, #6b7280); }
|
|
347
|
+
.auth-ui-form, .auth-ui-section {
|
|
348
|
+
display: flex; flex-direction: column; gap: 1rem;
|
|
349
|
+
}
|
|
350
|
+
.auth-ui-field { display: flex; flex-direction: column; gap: 0.375rem; }
|
|
351
|
+
.auth-ui-label { font-size: 0.875rem; font-weight: 500; }
|
|
352
|
+
.auth-ui-help { margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--auth-muted, #6b7280); text-align: center; }
|
|
353
|
+
.auth-ui-input {
|
|
354
|
+
padding: 0.5rem 0.75rem;
|
|
355
|
+
border: 1px solid var(--auth-border, #d1d5db);
|
|
356
|
+
border-radius: var(--auth-radius, 0.375rem);
|
|
357
|
+
font-size: 0.875rem;
|
|
358
|
+
}
|
|
359
|
+
.auth-ui-input:focus { outline: none; border-color: var(--auth-primary, #4f46e5); box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); }
|
|
360
|
+
.auth-ui-input--disabled { background: var(--auth-disabled-bg, #f3f4f6); color: var(--auth-muted, #6b7280); }
|
|
361
|
+
.auth-ui-code-row { display: flex; gap: 0.5rem; justify-content: center; }
|
|
362
|
+
.auth-ui-code-input {
|
|
363
|
+
width: 3rem; height: 3.5rem;
|
|
364
|
+
text-align: center;
|
|
365
|
+
font-size: 1.5rem; font-weight: 600;
|
|
366
|
+
border: 2px solid var(--auth-border, #d1d5db);
|
|
367
|
+
border-radius: var(--auth-radius-lg, 0.5rem);
|
|
368
|
+
}
|
|
369
|
+
.auth-ui-code-input:focus { outline: none; border-color: var(--auth-primary, #4f46e5); box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); }
|
|
370
|
+
.auth-ui-code-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
371
|
+
.auth-ui-button {
|
|
372
|
+
width: 100%;
|
|
373
|
+
padding: 0.625rem 1rem;
|
|
374
|
+
border: none;
|
|
375
|
+
border-radius: var(--auth-radius, 0.375rem);
|
|
376
|
+
font-size: 0.875rem; font-weight: 500;
|
|
377
|
+
cursor: pointer;
|
|
378
|
+
}
|
|
379
|
+
.auth-ui-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
380
|
+
.auth-ui-button--primary { background: var(--auth-primary, #4f46e5); color: var(--auth-primary-text, #ffffff); }
|
|
381
|
+
.auth-ui-button--ghost { background: var(--auth-ghost-bg, #ffffff); border: 1px solid var(--auth-border, #d1d5db); color: var(--auth-text, #111827); }
|
|
382
|
+
.auth-ui-alert {
|
|
383
|
+
padding: 1rem;
|
|
384
|
+
border-radius: var(--auth-radius, 0.375rem);
|
|
385
|
+
font-size: 0.875rem;
|
|
386
|
+
}
|
|
387
|
+
.auth-ui-alert--error { background: var(--auth-error-bg, #fef2f2); color: var(--auth-error-text, #991b1b); }
|
|
388
|
+
.auth-ui-alert--success { background: var(--auth-success-bg, #f0fdf4); color: var(--auth-success-text, #166534); }
|
|
389
|
+
.auth-ui-alert--info { background: var(--auth-info-bg, #eff6ff); color: var(--auth-info-text, #1e3a8a); }
|
|
390
|
+
.auth-ui-alert__title { margin: 0 0 0.25rem; font-size: 0.95rem; font-weight: 600; }
|
|
391
|
+
.auth-ui-actions { text-align: center; }
|
|
392
|
+
.auth-ui-link { color: var(--auth-link, #4f46e5); font-size: 0.875rem; font-weight: 500; text-decoration: none; }
|
|
393
|
+
.auth-ui-link:hover { text-decoration: underline; }
|
|
394
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import type {
|
|
3
|
+
AuthUiError,
|
|
4
|
+
AuthUiLabels,
|
|
5
|
+
AuthUiSession,
|
|
6
|
+
AuthUiTransport,
|
|
7
|
+
} from '../contracts.js';
|
|
8
|
+
|
|
9
|
+
export interface AuthRegisterProps {
|
|
10
|
+
transport: AuthUiTransport;
|
|
11
|
+
labels?: Partial<AuthUiLabels>;
|
|
12
|
+
onRegistered: (session: AuthUiSession) => void | Promise<void>;
|
|
13
|
+
onError?: (error: AuthUiError) => void;
|
|
14
|
+
/**
|
|
15
|
+
* When true, the component skips the email + code steps and renders only
|
|
16
|
+
* the WebAuthn step. Requires `presetEmail` and `presetVerificationToken`.
|
|
17
|
+
*/
|
|
18
|
+
skipEmailVerification?: boolean;
|
|
19
|
+
presetEmail?: string;
|
|
20
|
+
presetVerificationToken?: string;
|
|
21
|
+
deviceName?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
declare const AuthRegister: Component<AuthRegisterProps>;
|
|
25
|
+
export default AuthRegister;
|
package/src/contracts.ts
ADDED
package/src/errors.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AuthUiError, AuthUiErrorCode } from './types.js';
|
|
2
|
+
|
|
3
|
+
export const createAuthUiError = (
|
|
4
|
+
code: AuthUiErrorCode,
|
|
5
|
+
message: string,
|
|
6
|
+
options: { retryable?: boolean; cause?: unknown } = {},
|
|
7
|
+
): AuthUiError => {
|
|
8
|
+
const error = new Error(message) as AuthUiError;
|
|
9
|
+
Object.defineProperties(error, {
|
|
10
|
+
name: { value: 'AuthUiError', enumerable: true },
|
|
11
|
+
code: { value: code, enumerable: true },
|
|
12
|
+
retryable: { value: options.retryable ?? false, enumerable: true },
|
|
13
|
+
cause: { value: options.cause, enumerable: false },
|
|
14
|
+
});
|
|
15
|
+
return error;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const normalizeAuthEmail = (email: string): string => email.trim().toLowerCase();
|
package/src/index.ts
ADDED
package/src/labels.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { AuthUiBranding, AuthUiLabels } from './types.js';
|
|
2
|
+
|
|
3
|
+
export const createDefaultAuthUiLabels = (overrides: Partial<AuthUiLabels> = {}): AuthUiLabels => ({
|
|
4
|
+
loading: 'Loading...',
|
|
5
|
+
save: 'Save',
|
|
6
|
+
cancel: 'Cancel',
|
|
7
|
+
emailPlaceholder: 'you@example.com',
|
|
8
|
+
webauthnRegisterNotice: 'You are about to register a new WebAuthn device. We will send you a confirmation email.',
|
|
9
|
+
verifyInProgress: 'Verification in progress...',
|
|
10
|
+
redirectingDashboard: 'Redirecting to your dashboard...',
|
|
11
|
+
registerDeviceNow: 'You will now register your WebAuthn device.',
|
|
12
|
+
loginTitle: 'Sign in',
|
|
13
|
+
loginSupportedHint: 'Use a passkey to continue.',
|
|
14
|
+
loginUnavailable: 'Passkeys are not available in this browser.',
|
|
15
|
+
loginUnsupportedBrowser: 'Your browser does not support WebAuthn. Use a modern browser (Chrome, Firefox, Safari, Edge).',
|
|
16
|
+
loginButton: 'Sign in with passkey',
|
|
17
|
+
loginButtonLoading: 'Signing in...',
|
|
18
|
+
loginLostDevice: 'Lost your device?',
|
|
19
|
+
loginLostDeviceTitle: 'Lost device',
|
|
20
|
+
loginNoAccount: "Don't have an account? Sign up",
|
|
21
|
+
loginRegisterNewDevice: 'Register a new device',
|
|
22
|
+
loginBackToLogin: 'Back to sign in',
|
|
23
|
+
registerTitle: 'Create an account',
|
|
24
|
+
registerSubtitle: 'Secure authentication with WebAuthn.',
|
|
25
|
+
registerUnsupportedBrowserTitle: 'Browser not supported',
|
|
26
|
+
registerUnsupportedBrowser: 'Your browser does not support WebAuthn. Use a modern browser (Chrome, Firefox, Safari, Edge).',
|
|
27
|
+
registerEmailLabel: 'Email',
|
|
28
|
+
registerGetCode: 'Get verification code',
|
|
29
|
+
registerSendingCode: 'Sending code...',
|
|
30
|
+
registerAlreadyHaveAccount: 'Already have an account? Sign in',
|
|
31
|
+
registerCodeLabel: 'Verification code',
|
|
32
|
+
registerCodeHelp: 'Enter the 6-digit code we just emailed you.',
|
|
33
|
+
registerVerifyCode: 'Verify code',
|
|
34
|
+
registerVerifyingCode: 'Verifying...',
|
|
35
|
+
registerChangeEmail: 'Change email',
|
|
36
|
+
registerCodeVerifiedTitle: 'Code verified',
|
|
37
|
+
registerPasskeyButton: 'Register my WebAuthn device',
|
|
38
|
+
registerRegistering: 'Registering...',
|
|
39
|
+
registerSuccessTitle: 'Account created',
|
|
40
|
+
registerSuccessCodeSent: 'Verification code sent by email.',
|
|
41
|
+
registerSuccessRedirecting: 'Account created. Redirecting...',
|
|
42
|
+
registerErrorInvalidEmail: 'Please enter a valid email address.',
|
|
43
|
+
registerErrorSendCode: 'Failed to send the verification code.',
|
|
44
|
+
magicLinkTitle: 'Verifying magic link',
|
|
45
|
+
magicLinkVerifying: 'Verifying your link...',
|
|
46
|
+
magicLinkSuccess: 'You are signed in.',
|
|
47
|
+
magicLinkSuccessTitle: 'Signed in.',
|
|
48
|
+
magicLinkErrorTitle: 'Verification failed',
|
|
49
|
+
magicLinkBackToLogin: 'Back to sign in',
|
|
50
|
+
magicLinkErrorMissingToken: 'Token missing from the URL.',
|
|
51
|
+
magicLinkErrorVerifyFailed: 'Failed to verify the magic link.',
|
|
52
|
+
devicesTitle: 'My devices',
|
|
53
|
+
devicesSubtitle: 'Manage WebAuthn devices registered for this account.',
|
|
54
|
+
devicesEmpty: 'No devices registered yet.',
|
|
55
|
+
devicesRegister: 'Register a device',
|
|
56
|
+
devicesAddNew: 'Add a new device',
|
|
57
|
+
devicesRename: 'Rename',
|
|
58
|
+
devicesRevoke: 'Revoke',
|
|
59
|
+
devicesUvEnabled: 'UV enabled',
|
|
60
|
+
devicesAddedOn: 'Added on {date}',
|
|
61
|
+
devicesLastUsed: 'Last used: {date}',
|
|
62
|
+
devicesConfirmRevoke: 'Are you sure you want to revoke the device "{deviceName}"? You will no longer be able to sign in with it.',
|
|
63
|
+
devicesErrorLoad: 'Failed to load devices.',
|
|
64
|
+
devicesErrorUpdate: 'Update failed.',
|
|
65
|
+
devicesErrorRevoke: 'Revoke failed.',
|
|
66
|
+
devicePairTitle: 'Pair a device',
|
|
67
|
+
devicePairSubtitle: 'Enter the code displayed by the companion app on your workstation.',
|
|
68
|
+
devicePairCodeLabel: 'Pairing code',
|
|
69
|
+
devicePairCodePlaceholder: 'PAIR-XXXX',
|
|
70
|
+
devicePairDeviceNameLabel: 'Device name',
|
|
71
|
+
devicePairDeviceNamePlaceholder: 'My workstation',
|
|
72
|
+
devicePairConfirm: 'Pair device',
|
|
73
|
+
devicePairConfirming: 'Pairing...',
|
|
74
|
+
devicePairSuccess: 'Device paired. You can return to the companion app.',
|
|
75
|
+
devicePairBack: 'Back to devices',
|
|
76
|
+
devicePairErrorCodeRequired: 'The pairing code is required.',
|
|
77
|
+
devicePairErrorNotFound: 'Invalid or expired code.',
|
|
78
|
+
devicePairErrorGeneric: 'Failed to pair the device.',
|
|
79
|
+
passkeyNotSupported: 'WebAuthn is not supported in this browser',
|
|
80
|
+
passkeyCancelled: 'Passkey operation cancelled by user',
|
|
81
|
+
passkeyAlreadyRegistered: 'This authenticator is already registered',
|
|
82
|
+
passkeyNoMatchingAuthenticator: 'No matching authenticator found',
|
|
83
|
+
passkeyAuthenticatorNotSupported: 'This authenticator is not supported',
|
|
84
|
+
passkeyRegistrationCancelled: 'You cancelled the registration.',
|
|
85
|
+
passkeyAuthenticationCancelled: 'You cancelled the authentication.',
|
|
86
|
+
...overrides,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const createFrenchAuthUiLabels = (overrides: Partial<AuthUiLabels> = {}): AuthUiLabels =>
|
|
90
|
+
createDefaultAuthUiLabels({
|
|
91
|
+
loading: 'Chargement…',
|
|
92
|
+
save: 'Enregistrer',
|
|
93
|
+
cancel: 'Annuler',
|
|
94
|
+
emailPlaceholder: 'vous@example.com',
|
|
95
|
+
webauthnRegisterNotice: "Vous allez enregistrer un nouvel appareil WebAuthn. Nous vous enverrons un email de confirmation.",
|
|
96
|
+
verifyInProgress: 'Vérification en cours…',
|
|
97
|
+
redirectingDashboard: 'Redirection vers le tableau de bord…',
|
|
98
|
+
registerDeviceNow: 'Vous allez maintenant enregistrer votre appareil WebAuthn',
|
|
99
|
+
loginTitle: 'Connexion',
|
|
100
|
+
loginSupportedHint: 'Utilisez votre passkey ou biométrie',
|
|
101
|
+
loginUnavailable: 'WebAuthn non disponible sur ce navigateur',
|
|
102
|
+
loginUnsupportedBrowser: 'Votre navigateur ne supporte pas WebAuthn. Utilisez un navigateur moderne (Chrome, Firefox, Safari, Edge).',
|
|
103
|
+
loginButton: 'Se connecter avec WebAuthn',
|
|
104
|
+
loginButtonLoading: 'Connexion…',
|
|
105
|
+
loginLostDevice: "J'ai perdu mon appareil",
|
|
106
|
+
loginLostDeviceTitle: "Perte d'appareil",
|
|
107
|
+
loginNoAccount: "Pas encore de compte ? S'inscrire",
|
|
108
|
+
loginRegisterNewDevice: 'Enregistrer un nouvel appareil (workflow complet)',
|
|
109
|
+
loginBackToLogin: 'Retour à la connexion normale',
|
|
110
|
+
registerTitle: 'Créer un compte',
|
|
111
|
+
registerSubtitle: 'Authentification sécurisée avec WebAuthn',
|
|
112
|
+
registerUnsupportedBrowserTitle: 'Navigateur non compatible',
|
|
113
|
+
registerUnsupportedBrowser: 'Votre navigateur ne supporte pas WebAuthn. Utilisez un navigateur moderne (Chrome, Firefox, Safari, Edge).',
|
|
114
|
+
registerEmailLabel: 'Email',
|
|
115
|
+
registerGetCode: 'Obtenir un code',
|
|
116
|
+
registerSendingCode: 'Envoi…',
|
|
117
|
+
registerAlreadyHaveAccount: 'Déjà un compte ? Se connecter',
|
|
118
|
+
registerCodeLabel: 'Code à 6 chiffres',
|
|
119
|
+
registerCodeHelp: 'Saisissez le code reçu par email',
|
|
120
|
+
registerVerifyCode: 'Vérifier le code',
|
|
121
|
+
registerVerifyingCode: 'Vérification…',
|
|
122
|
+
registerChangeEmail: "Modifier l'email",
|
|
123
|
+
registerCodeVerifiedTitle: 'Code vérifié !',
|
|
124
|
+
registerPasskeyButton: 'Enregistrer mon appareil WebAuthn',
|
|
125
|
+
registerRegistering: 'Enregistrement…',
|
|
126
|
+
registerSuccessTitle: 'Inscription réussie !',
|
|
127
|
+
registerSuccessCodeSent: 'Code envoyé par email',
|
|
128
|
+
registerSuccessRedirecting: 'Inscription réussie ! Redirection…',
|
|
129
|
+
registerErrorInvalidEmail: 'Veuillez saisir une adresse email valide',
|
|
130
|
+
registerErrorSendCode: "Erreur lors de l'envoi du code",
|
|
131
|
+
magicLinkTitle: 'Vérification du lien magique',
|
|
132
|
+
magicLinkVerifying: 'Vérification en cours…',
|
|
133
|
+
magicLinkSuccess: 'Vous êtes connecté.',
|
|
134
|
+
magicLinkSuccessTitle: 'Connexion réussie !',
|
|
135
|
+
magicLinkErrorTitle: 'Erreur de vérification',
|
|
136
|
+
magicLinkBackToLogin: 'Retour à la connexion',
|
|
137
|
+
magicLinkErrorMissingToken: 'Token manquant dans l\'URL',
|
|
138
|
+
magicLinkErrorVerifyFailed: 'Erreur lors de la vérification du lien magique',
|
|
139
|
+
devicesTitle: 'Mes appareils',
|
|
140
|
+
devicesSubtitle: "Gérez les appareils enregistrés pour l'authentification WebAuthn",
|
|
141
|
+
devicesEmpty: 'Aucun appareil enregistré',
|
|
142
|
+
devicesRegister: 'Enregistrer un appareil',
|
|
143
|
+
devicesAddNew: 'Ajouter un nouvel appareil',
|
|
144
|
+
devicesRename: 'Renommer',
|
|
145
|
+
devicesRevoke: 'Révoquer',
|
|
146
|
+
devicesUvEnabled: 'UV activée',
|
|
147
|
+
devicesAddedOn: 'Ajouté le {date}',
|
|
148
|
+
devicesLastUsed: 'Dernière utilisation : {date}',
|
|
149
|
+
devicesConfirmRevoke: 'Êtes-vous sûr de vouloir révoquer l\'appareil "{deviceName}" ? Vous ne pourrez plus vous connecter avec cet appareil.',
|
|
150
|
+
devicesErrorLoad: 'Erreur lors du chargement des appareils',
|
|
151
|
+
devicesErrorUpdate: 'Erreur lors de la mise à jour',
|
|
152
|
+
devicesErrorRevoke: 'Erreur lors de la révocation',
|
|
153
|
+
devicePairTitle: 'Appairer un appareil',
|
|
154
|
+
devicePairSubtitle: "Saisissez le code affiché par l'application compagnon sur votre poste de travail.",
|
|
155
|
+
devicePairCodeLabel: "Code d'appairage",
|
|
156
|
+
devicePairCodePlaceholder: 'PAIR-XXXX',
|
|
157
|
+
devicePairDeviceNameLabel: "Nom de l'appareil",
|
|
158
|
+
devicePairDeviceNamePlaceholder: 'Mon poste de travail',
|
|
159
|
+
devicePairConfirm: "Appairer l'appareil",
|
|
160
|
+
devicePairConfirming: 'Appairage…',
|
|
161
|
+
devicePairSuccess: 'Appareil appairé. Vous pouvez retourner à l\'application sur votre poste de travail.',
|
|
162
|
+
devicePairBack: 'Retour aux appareils',
|
|
163
|
+
devicePairErrorCodeRequired: "Le code d'appairage est requis.",
|
|
164
|
+
devicePairErrorNotFound: 'Code invalide ou expiré.',
|
|
165
|
+
devicePairErrorGeneric: "Échec de l'appairage de l'appareil.",
|
|
166
|
+
passkeyRegistrationCancelled: "Vous avez annulé l'inscription",
|
|
167
|
+
passkeyAuthenticationCancelled: "Vous avez annulé l'authentification",
|
|
168
|
+
passkeyAlreadyRegistered: 'Cet appareil est déjà enregistré',
|
|
169
|
+
passkeyNoMatchingAuthenticator: 'Aucun appareil reconnu',
|
|
170
|
+
passkeyAuthenticatorNotSupported: "Cet authentificateur n'est pas supporté",
|
|
171
|
+
...overrides,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
export const createDefaultAuthUiBranding = (overrides: Partial<AuthUiBranding> = {}): AuthUiBranding => {
|
|
175
|
+
const branding: AuthUiBranding = {
|
|
176
|
+
primaryColor: '#4f46e5',
|
|
177
|
+
accentColor: '#0f766e',
|
|
178
|
+
...overrides,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (branding.productName && !branding.logoAlt) {
|
|
182
|
+
branding.logoAlt = branding.productName;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return branding;
|
|
186
|
+
};
|