@nymphjs/tilmeld-components 1.0.0-beta.13 → 1.0.0-beta.130

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.
@@ -0,0 +1,603 @@
1
+ <svelte:options runes />
2
+
3
+ <div
4
+ style="width: {width}; {style}"
5
+ use:useActions={use}
6
+ {...exclude(restProps, [
7
+ 'successRegisteredMessage$',
8
+ 'successLoginMessage$',
9
+ 'username$',
10
+ 'password$',
11
+ 'password2$',
12
+ 'name$',
13
+ 'email$',
14
+ 'phone$',
15
+ 'code$',
16
+ 'codeMessage$',
17
+ 'loginButton$',
18
+ 'registerButton$',
19
+ 'registerLink$',
20
+ 'loginLink$',
21
+ 'recoverLink$',
22
+ 'recover$',
23
+ 'progress$',
24
+ ])}
25
+ >
26
+ {#if $clientConfig == null || loading}
27
+ <div style="display: flex; justify-content: center; align-items: center;">
28
+ <CircularProgress
29
+ style="height: 32px; width: 32px;"
30
+ indeterminate
31
+ {...prefixFilter(restProps, 'progress$')}
32
+ />
33
+ </div>
34
+ {:else if successRegisteredMessage}
35
+ <div {...prefixFilter(restProps, 'successRegisteredMessage$')}>
36
+ {successRegisteredMessage}
37
+ </div>
38
+ {:else if successLoginMessage}
39
+ <div {...prefixFilter(restProps, 'successLoginMessage$')}>
40
+ {successLoginMessage}
41
+ </div>
42
+ {:else if totp}
43
+ <form onsubmit={(e) => e.preventDefault()}>
44
+ <div {...prefixFilter(restProps, 'codeMessage$')}>
45
+ Enter the 6-digit 2FA code from your authenticator app.
46
+ </div>
47
+
48
+ <div>
49
+ <Textfield
50
+ bind:value={code}
51
+ label="2FA Code"
52
+ style="width: 100%;"
53
+ input$autocomplete="one-time-code"
54
+ input$name="one-time-code"
55
+ {...prefixFilter(restProps, 'code$')}
56
+ />
57
+ </div>
58
+
59
+ {#if failureMessage}
60
+ <div class="tilmeld-login-failure">
61
+ {failureMessage}
62
+ </div>
63
+ {/if}
64
+
65
+ <div class="tilmeld-login-buttons">
66
+ <Button
67
+ variant="raised"
68
+ type="submit"
69
+ onclick={login}
70
+ {...prefixFilter(restProps, 'loginButton$')}
71
+ >
72
+ <Label>Log In</Label>
73
+ </Button>
74
+ </div>
75
+ </form>
76
+ {:else}
77
+ <form onsubmit={(e) => e.preventDefault()}>
78
+ <div>
79
+ <Textfield
80
+ bind:value={username}
81
+ bind:this={usernameElem}
82
+ label={$clientConfig.emailUsernames ? 'Email' : 'Username'}
83
+ type={$clientConfig.emailUsernames ? 'email' : 'text'}
84
+ style="width: 100%;"
85
+ helperLine$style="width: 100%;"
86
+ invalid={usernameVerified === false}
87
+ input$autocomplete={$clientConfig.emailUsernames
88
+ ? 'email'
89
+ : 'username'}
90
+ input$name={$clientConfig.emailUsernames ? 'email' : 'username'}
91
+ input$autocapitalize="off"
92
+ input$spellcheck="false"
93
+ {...prefixFilter(restProps, 'username$')}
94
+ >
95
+ {#snippet helper()}
96
+ <HelperText persistent>
97
+ {#if mode !== 'login'}
98
+ {usernameVerifiedMessage || ''}
99
+ {/if}
100
+ </HelperText>
101
+ {/snippet}
102
+ </Textfield>
103
+ </div>
104
+
105
+ <div>
106
+ <Textfield
107
+ bind:value={password}
108
+ label="Password"
109
+ type="password"
110
+ style="width: 100%;"
111
+ input$autocomplete="{$clientConfig.allowRegistration &&
112
+ mode !== 'login'
113
+ ? 'new'
114
+ : 'current'}-password"
115
+ input$name="password"
116
+ {...prefixFilter(restProps, 'password$')}
117
+ />
118
+ </div>
119
+
120
+ {#if $clientConfig.allowRegistration && mode !== 'login'}
121
+ <div>
122
+ <Textfield
123
+ bind:value={password2}
124
+ label="Re-enter Password"
125
+ type="password"
126
+ style="width: 100%;"
127
+ input$autocomplete="new-password"
128
+ input$name="password2"
129
+ {...prefixFilter(restProps, 'password2$')}
130
+ />
131
+ </div>
132
+
133
+ {#if $clientConfig.regFields.includes('name')}
134
+ <div>
135
+ <Textfield
136
+ bind:value={name}
137
+ label="Name"
138
+ type="text"
139
+ style="width: 100%;"
140
+ input$autocomplete="name"
141
+ input$name="name"
142
+ {...prefixFilter(restProps, 'name$')}
143
+ />
144
+ </div>
145
+ {/if}
146
+
147
+ {#if !$clientConfig.emailUsernames && $clientConfig.regFields.includes('email')}
148
+ <div>
149
+ <Textfield
150
+ bind:value={email}
151
+ label="Email"
152
+ type="email"
153
+ style="width: 100%;"
154
+ input$autocomplete="email"
155
+ input$name="email"
156
+ input$autocapitalize="off"
157
+ input$spellcheck="false"
158
+ {...prefixFilter(restProps, 'email$')}
159
+ />
160
+ </div>
161
+ {/if}
162
+
163
+ {#if $clientConfig.regFields.includes('phone')}
164
+ <div>
165
+ <Textfield
166
+ bind:value={phone}
167
+ label="Phone Number"
168
+ type="tel"
169
+ style="width: 100%;"
170
+ input$autocomplete="tel"
171
+ input$name="phone"
172
+ {...prefixFilter(restProps, 'phone$')}
173
+ />
174
+ </div>
175
+ {/if}
176
+ {/if}
177
+
178
+ {@render additional?.()}
179
+
180
+ {#if failureMessage}
181
+ <div class="tilmeld-login-failure">
182
+ {failureMessage}
183
+ </div>
184
+ {/if}
185
+
186
+ <div class="tilmeld-login-buttons">
187
+ {#if mode === 'login'}
188
+ <Button
189
+ variant="raised"
190
+ type="submit"
191
+ onclick={login}
192
+ {...prefixFilter(restProps, 'loginButton$')}
193
+ >
194
+ <Label>Log In</Label>
195
+ </Button>
196
+ {:else}
197
+ <Button
198
+ variant="raised"
199
+ type="submit"
200
+ onclick={register}
201
+ {...prefixFilter(restProps, 'registerButton$')}
202
+ >
203
+ <Label>Create Account</Label>
204
+ </Button>
205
+ {/if}
206
+ </div>
207
+
208
+ {#if $clientConfig.allowRegistration && showExistingUserToggle}
209
+ <div class="tilmeld-login-action">
210
+ {#if mode === 'login'}
211
+ <a
212
+ href={'javascript:void(0);'}
213
+ onclick={() => (mode = 'register')}
214
+ {...prefixFilter(restProps, 'registerLink$')}
215
+ >
216
+ Create an account.
217
+ </a>
218
+ {:else}
219
+ <a
220
+ href={'javascript:void(0);'}
221
+ onclick={() => (mode = 'login')}
222
+ {...prefixFilter(restProps, 'loginLink$')}
223
+ >
224
+ Log in to your account.
225
+ </a>
226
+ {/if}
227
+ </div>
228
+ {/if}
229
+
230
+ {#if !hideRecovery && $clientConfig.userFields.includes('email') && $clientConfig.pwRecovery && mode === 'login'}
231
+ <div class="tilmeld-login-action">
232
+ <a
233
+ href={'javascript:void(0);'}
234
+ onclick={() => (recoverOpen = true)}
235
+ {...prefixFilter(restProps, 'recoverLink$')}
236
+ >
237
+ I can't access my account.
238
+ </a>
239
+ </div>
240
+ <Recover
241
+ {User}
242
+ bind:open={recoverOpen}
243
+ bind:clientConfig
244
+ {...prefixFilter(restProps, 'recover$')}
245
+ />
246
+ {/if}
247
+ </form>
248
+ {/if}
249
+ </div>
250
+
251
+ <script lang="ts">
252
+ import type { ComponentProps, Snippet } from 'svelte';
253
+ import { onMount } from 'svelte';
254
+ import type { Writable } from 'svelte/store';
255
+ import { writable } from 'svelte/store';
256
+ import CircularProgress from '@smui/circular-progress';
257
+ import Button, { Label } from '@smui/button';
258
+ import Textfield from '@smui/textfield';
259
+ import HelperText from '@smui/textfield/helper-text';
260
+ import type { ActionArray } from '@smui/common/internal';
261
+ import { exclude, prefixFilter, useActions } from '@smui/common/internal';
262
+ import type { SmuiElementPropMap } from '@smui/common';
263
+ import type {
264
+ User as UserClass,
265
+ ClientConfig,
266
+ UserData,
267
+ CurrentUserData,
268
+ } from '@nymphjs/tilmeld-client';
269
+ import {
270
+ login as loginAction,
271
+ register as registerAction,
272
+ checkUsername as checkUsernameAction,
273
+ NeedTOTPError,
274
+ } from '@nymphjs/tilmeld-client';
275
+ import Recover from './Recover.svelte';
276
+
277
+ type OwnProps = {
278
+ /**
279
+ * An array of Action or [Action, ActionProps] to be applied to the element.
280
+ */
281
+ use?: ActionArray;
282
+ /**
283
+ * A list of CSS styles.
284
+ */
285
+ style?: string;
286
+ /**
287
+ * A writable store of the Nymph client config.
288
+ *
289
+ * It will be retrieved from the server if not provided.
290
+ */
291
+ clientConfig?: Writable<ClientConfig | undefined>;
292
+ /**
293
+ * The User class from Nymph.
294
+ */
295
+ User: typeof UserClass;
296
+ /**
297
+ * Hide the recovery link that only appears if password recovery is on.
298
+ */
299
+ hideRecovery?: boolean;
300
+ /**
301
+ * Give focus to the username box (or email box) when the form is ready.
302
+ */
303
+ autofocus?: boolean;
304
+ /**
305
+ * This determines whether the 'Log In' or 'Sign Up' button is activated
306
+ * and which corresponding form is shown.
307
+ */
308
+ mode?: 'login' | 'register';
309
+ /**
310
+ * Whether to show the 'Log In'/'Sign Up' switcher buttons.
311
+ */
312
+ showExistingUserToggle?: boolean;
313
+ /**
314
+ * The width of the form.
315
+ */
316
+ width?: string;
317
+ /**
318
+ * User provided. You can bind to it if you need to.
319
+ */
320
+ username?: string;
321
+ /**
322
+ * User provided. You can bind to it if you need to.
323
+ */
324
+ password?: string;
325
+ /**
326
+ * User provided. You can bind to it if you need to.
327
+ */
328
+ password2?: string;
329
+ /**
330
+ * User provided. You can bind to it if you need to.
331
+ */
332
+ name?: string;
333
+ /**
334
+ * User provided. You can bind to it if you need to.
335
+ */
336
+ email?: string;
337
+ /**
338
+ * User provided. You can bind to it if you need to.
339
+ */
340
+ phone?: string;
341
+ /**
342
+ * User provided. You can bind to it if you need to.
343
+ */
344
+ code?: string;
345
+ /**
346
+ * Provide this for anything else that should go up to the server.
347
+ *
348
+ * You can look for it in the User events.
349
+ */
350
+ additionalData?: { [k: string]: any };
351
+
352
+ /**
353
+ * A spot for additional content.
354
+ */
355
+ additional?: Snippet;
356
+
357
+ /**
358
+ * A callback to be called when a user is registered.
359
+ */
360
+ onregister?: (details: {
361
+ user: UserClass & UserData & CurrentUserData;
362
+ }) => void;
363
+ /**
364
+ * A callback to be called when a user is logged in.
365
+ */
366
+ onlogin?: (details: {
367
+ user: UserClass & UserData & CurrentUserData;
368
+ }) => void;
369
+ };
370
+ let {
371
+ use = [],
372
+ style = '',
373
+ clientConfig = $bindable(writable(false as unknown as undefined)),
374
+ User,
375
+ hideRecovery = false,
376
+ autofocus = true,
377
+ mode = $bindable('login'),
378
+ showExistingUserToggle = true,
379
+ width = '220px',
380
+ username = $bindable(''),
381
+ password = $bindable(''),
382
+ password2 = $bindable(''),
383
+ name = $bindable(''),
384
+ email = $bindable(''),
385
+ phone = $bindable(''),
386
+ code = $bindable(''),
387
+ additionalData,
388
+ additional,
389
+ onregister,
390
+ onlogin,
391
+ ...restProps
392
+ }: OwnProps & {
393
+ [
394
+ k in keyof SmuiElementPropMap['div'] as `successRegisteredMessage\$${k}`
395
+ ]?: SmuiElementPropMap['div'][k];
396
+ } & {
397
+ [
398
+ k in keyof SmuiElementPropMap['div'] as `successLoginMessage\$${k}`
399
+ ]?: SmuiElementPropMap['div'][k];
400
+ } & {
401
+ [
402
+ k in keyof ComponentProps<typeof Textfield> as `username\$${k}`
403
+ ]?: ComponentProps<typeof Textfield>[k];
404
+ } & {
405
+ [
406
+ k in keyof ComponentProps<typeof Textfield> as `password\$${k}`
407
+ ]?: ComponentProps<typeof Textfield>[k];
408
+ } & {
409
+ [
410
+ k in keyof ComponentProps<typeof Textfield> as `password2\$${k}`
411
+ ]?: ComponentProps<typeof Textfield>[k];
412
+ } & {
413
+ [
414
+ k in keyof ComponentProps<typeof Textfield> as `name\$${k}`
415
+ ]?: ComponentProps<typeof Textfield>[k];
416
+ } & {
417
+ [
418
+ k in keyof ComponentProps<typeof Textfield> as `email\$${k}`
419
+ ]?: ComponentProps<typeof Textfield>[k];
420
+ } & {
421
+ [
422
+ k in keyof ComponentProps<typeof Textfield> as `phone\$${k}`
423
+ ]?: ComponentProps<typeof Textfield>[k];
424
+ } & {
425
+ [
426
+ k in keyof ComponentProps<typeof Textfield> as `code\$${k}`
427
+ ]?: ComponentProps<typeof Textfield>[k];
428
+ } & {
429
+ [
430
+ k in keyof SmuiElementPropMap['div'] as `codeMessage\$${k}`
431
+ ]?: SmuiElementPropMap['div'][k];
432
+ } & {
433
+ [
434
+ k in keyof ComponentProps<typeof CircularProgress> as `progress\$${k}`
435
+ ]?: ComponentProps<typeof CircularProgress>[k];
436
+ } & {
437
+ [
438
+ k in keyof ComponentProps<
439
+ typeof Button<undefined, 'button'>
440
+ > as `loginButton\$${k}`
441
+ ]?: ComponentProps<typeof Button<undefined, 'button'>>[k];
442
+ } & {
443
+ [
444
+ k in keyof ComponentProps<
445
+ typeof Button<undefined, 'button'>
446
+ > as `registerButton\$${k}`
447
+ ]?: ComponentProps<typeof Button<undefined, 'button'>>[k];
448
+ } & {
449
+ [
450
+ k in keyof SmuiElementPropMap['a'] as `registerLink\$${k}`
451
+ ]?: SmuiElementPropMap['a'][k];
452
+ } & {
453
+ [
454
+ k in keyof SmuiElementPropMap['a'] as `loginLink\$${k}`
455
+ ]?: SmuiElementPropMap['a'][k];
456
+ } & {
457
+ [
458
+ k in keyof SmuiElementPropMap['a'] as `recoverLink\$${k}`
459
+ ]?: SmuiElementPropMap['a'][k];
460
+ } & {
461
+ [
462
+ k in keyof ComponentProps<typeof Recover> as `recover\$${k}`
463
+ ]?: ComponentProps<typeof Recover>[k];
464
+ } = $props();
465
+
466
+ let usernameElem: Textfield | undefined = $state();
467
+ let successLoginMessage: string | undefined = $state();
468
+ let successRegisteredMessage: string | undefined = $state();
469
+ let failureMessage: string | undefined = $state();
470
+ let usernameTimer: number | undefined = undefined;
471
+ let usernameVerified: boolean | undefined = $state();
472
+ let usernameVerifiedMessage: string | undefined = $state();
473
+ let totp = $state(false);
474
+ let loading = $state(false);
475
+ let recoverOpen = $state(false);
476
+
477
+ const nameFirst = $derived(name?.match(/^(.*?)(?: ([^ ]+))?$/)?.[1] ?? '');
478
+ const nameLast = $derived(name?.match(/^(.*?)(?: ([^ ]+))?$/)?.[2] ?? '');
479
+
480
+ let _previousMode = mode;
481
+ $effect(() => {
482
+ if (mode !== _previousMode) {
483
+ failureMessage = '';
484
+ checkUsername(username);
485
+ _previousMode = mode;
486
+ }
487
+ });
488
+
489
+ $effect(() => {
490
+ checkUsername(username);
491
+ });
492
+
493
+ onMount(async () => {
494
+ if ($clientConfig === (false as unknown as undefined)) {
495
+ $clientConfig = undefined;
496
+ $clientConfig = await User.getClientConfig();
497
+ }
498
+ if (!$clientConfig.allowRegistration) {
499
+ mode = 'login';
500
+ }
501
+ if (autofocus && usernameElem) {
502
+ usernameElem.focus();
503
+ }
504
+ });
505
+
506
+ async function login() {
507
+ successLoginMessage = undefined;
508
+ failureMessage = undefined;
509
+ loading = true;
510
+ try {
511
+ const data = await loginAction(
512
+ User,
513
+ username,
514
+ password,
515
+ code,
516
+ additionalData,
517
+ );
518
+ successLoginMessage = data.message;
519
+ if (data.user) {
520
+ onlogin?.({ user: data.user });
521
+ }
522
+ } catch (e: any) {
523
+ if (e instanceof NeedTOTPError) {
524
+ totp = true;
525
+ } else {
526
+ failureMessage = e?.message;
527
+ }
528
+ }
529
+ loading = false;
530
+ }
531
+
532
+ async function register() {
533
+ successRegisteredMessage = undefined;
534
+ successLoginMessage = undefined;
535
+ failureMessage = undefined;
536
+ loading = true;
537
+ try {
538
+ const data = await registerAction(User, {
539
+ username,
540
+ usernameVerified: !!usernameVerified,
541
+ password,
542
+ password2,
543
+ email,
544
+ nameFirst,
545
+ nameLast,
546
+ phone,
547
+ ...(additionalData ? { additionalData } : {}),
548
+ });
549
+ successRegisteredMessage = data.message;
550
+ if (data.user) {
551
+ onregister?.({ user: data.user });
552
+ }
553
+ if (data.loggedin) {
554
+ successLoginMessage = data.message;
555
+ if (data.user) {
556
+ onlogin?.({ user: data.user });
557
+ }
558
+ }
559
+ } catch (e: any) {
560
+ failureMessage = e?.message;
561
+ }
562
+ loading = false;
563
+ }
564
+
565
+ function checkUsername(newValue: string) {
566
+ usernameVerified = undefined;
567
+ usernameVerifiedMessage = undefined;
568
+ if (usernameTimer) {
569
+ clearTimeout(usernameTimer);
570
+ usernameTimer = undefined;
571
+ }
572
+ if (newValue === '' || mode === 'login') {
573
+ return;
574
+ }
575
+ usernameTimer = setTimeout(async () => {
576
+ try {
577
+ const data = await checkUsernameAction(User, newValue);
578
+ usernameVerified = true;
579
+ usernameVerifiedMessage = data.message;
580
+ } catch (e: any) {
581
+ usernameVerified = false;
582
+ usernameVerifiedMessage = e?.message;
583
+ }
584
+ }, 400);
585
+ }
586
+ </script>
587
+
588
+ <style>
589
+ .tilmeld-login-buttons {
590
+ display: flex;
591
+ flex-direction: row;
592
+ justify-content: start;
593
+ align-items: center;
594
+ margin-top: 1em;
595
+ }
596
+ .tilmeld-login-action {
597
+ margin-top: 1em;
598
+ }
599
+ .tilmeld-login-failure {
600
+ margin-top: 1em;
601
+ color: var(--mdc-theme-error, #f00);
602
+ }
603
+ </style>