@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,463 @@
1
+ <svelte:options runes />
2
+
3
+ {#if $clientConfig != null && $clientConfig.pwRecovery}
4
+ <Dialog
5
+ {use}
6
+ bind:open
7
+ aria-labelledby="tilmeld-recovery-title"
8
+ aria-describedby="tilmeld-recovery-content"
9
+ surface$class="tilmeld-recover-dialog-surface"
10
+ {...exclude(restProps, [
11
+ 'recoveryTypePassword$',
12
+ 'recoveryTypeUsername$',
13
+ 'account$',
14
+ 'alreadyGotCodeLink$',
15
+ 'codeSentMessage$',
16
+ 'recoveryCode$',
17
+ 'password$',
18
+ 'password2$',
19
+ 'needCodeLink$',
20
+ 'sendCodeButton$',
21
+ 'resetPasswordButton$',
22
+ 'progress$',
23
+ ])}
24
+ >
25
+ <!-- Title cannot contain leading whitespace due to mdc-typography-baseline-top() -->
26
+ <Title id="tilmeld-recovery-title">{title}</Title>
27
+ <Content id="tilmeld-recovery-content">
28
+ {#if successRecoveredMessage}
29
+ {successRecoveredMessage}
30
+ {:else}
31
+ {#if !hasSentSecret}
32
+ {#if !$clientConfig.emailUsernames}
33
+ <div>
34
+ <FormField style="margin-right: 1em;">
35
+ <Radio
36
+ bind:group={recoveryType}
37
+ value="password"
38
+ {...prefixFilter(restProps, 'recoveryTypePassword$')}
39
+ />
40
+ {#snippet label()}
41
+ I don't know my password.
42
+ {/snippet}
43
+ </FormField>
44
+ <FormField style="margin-right: 1em;">
45
+ <Radio
46
+ bind:group={recoveryType}
47
+ value="username"
48
+ {...prefixFilter(restProps, 'recoveryTypeUsername$')}
49
+ />
50
+ {#snippet label()}
51
+ I don't know my username.
52
+ {/snippet}
53
+ </FormField>
54
+ </div>
55
+ {/if}
56
+
57
+ <div>
58
+ {#if recoveryType === 'password'}
59
+ <p>
60
+ To reset your password, type the {$clientConfig.emailUsernames
61
+ ? 'email'
62
+ : 'username'}
63
+ you use to sign in below.
64
+ </p>
65
+ {/if}
66
+ {#if recoveryType === 'username'}
67
+ <p>
68
+ To get your username, type your email as you entered it when
69
+ creating your account.
70
+ </p>
71
+ {/if}
72
+ </div>
73
+
74
+ <div>
75
+ <Textfield
76
+ bind:this={accountElem}
77
+ bind:value={account}
78
+ label={$clientConfig.emailUsernames || recoveryType === 'username'
79
+ ? 'Email Address'
80
+ : 'Username'}
81
+ type={$clientConfig.emailUsernames || recoveryType === 'username'
82
+ ? 'email'
83
+ : 'text'}
84
+ input$autocomplete={$clientConfig.emailUsernames ||
85
+ recoveryType === 'username'
86
+ ? 'email'
87
+ : 'username'}
88
+ input$autocapitalize="off"
89
+ input$spellcheck="false"
90
+ {...prefixFilter(restProps, 'account$')}
91
+ />
92
+ </div>
93
+
94
+ {#if recoveryType === 'password'}
95
+ <div class="tilmeld-recover-action">
96
+ <a
97
+ href={'javascript:void(0);'}
98
+ onclick={() => (hasSentSecret = 1)}
99
+ {...prefixFilter(restProps, 'alreadyGotCodeLink$')}
100
+ >
101
+ Already Got a Code?
102
+ </a>
103
+ </div>
104
+ {/if}
105
+ {:else}
106
+ <div>
107
+ <p {...prefixFilter(restProps, 'codeSentMessage$')}>
108
+ A code has been sent to you by email. Enter that code here, and a
109
+ new password for your account.
110
+ </p>
111
+ </div>
112
+
113
+ {#if hasSentSecret === 1}
114
+ <div>
115
+ <Textfield
116
+ bind:this={accountElem}
117
+ bind:value={account}
118
+ label={$clientConfig.emailUsernames
119
+ ? 'Email Address'
120
+ : 'Username'}
121
+ type={$clientConfig.emailUsernames ? 'email' : 'text'}
122
+ input$autocomplete={$clientConfig.emailUsernames
123
+ ? 'email'
124
+ : 'username'}
125
+ input$autocapitalize="off"
126
+ input$spellcheck="false"
127
+ {...prefixFilter(restProps, 'account$')}
128
+ />
129
+ </div>
130
+ {/if}
131
+
132
+ <div>
133
+ <Textfield
134
+ bind:value={secret}
135
+ label="Recovery Code"
136
+ type="text"
137
+ input$autocomplete="one-time-code"
138
+ {...prefixFilter(restProps, 'recoveryCode$')}
139
+ />
140
+ </div>
141
+
142
+ <div>
143
+ <Textfield
144
+ bind:value={password}
145
+ label="Password"
146
+ type="password"
147
+ input$autocomplete="new-password"
148
+ {...prefixFilter(restProps, 'password$')}
149
+ />
150
+ </div>
151
+
152
+ <div>
153
+ <Textfield
154
+ bind:value={password2}
155
+ label="Re-enter Password"
156
+ type="password"
157
+ input$autocomplete="new-password"
158
+ {...prefixFilter(restProps, 'password2$')}
159
+ />
160
+ </div>
161
+
162
+ <div class="tilmeld-recover-action">
163
+ <a
164
+ href={'javascript:void(0);'}
165
+ onclick={() => (hasSentSecret = false)}
166
+ {...prefixFilter(restProps, 'needCodeLink$')}
167
+ >
168
+ Need a New Code?
169
+ </a>
170
+ </div>
171
+ {/if}
172
+
173
+ {#if failureMessage}
174
+ <div class="tilmeld-recover-failure">
175
+ {failureMessage}
176
+ </div>
177
+ {/if}
178
+
179
+ {#if loading}
180
+ <div class="tilmeld-recover-loading">
181
+ <CircularProgress
182
+ style="height: 24px; width: 24px;"
183
+ indeterminate
184
+ {...prefixFilter(restProps, 'progress$')}
185
+ />
186
+ </div>
187
+ {/if}
188
+ {/if}
189
+ </Content>
190
+ <Actions>
191
+ <Button onclick={() => (open = false)} disabled={loading}>
192
+ <Label>{successRecoveredMessage ? 'Close' : 'Cancel'}</Label>
193
+ </Button>
194
+ {#if !successRecoveredMessage}
195
+ {#if !hasSentSecret}
196
+ <Button
197
+ onclick={preventDefault(stopPropagation(sendRecovery))}
198
+ disabled={loading}
199
+ {...prefixFilter(restProps, 'sendCodeButton$')}
200
+ >
201
+ <Label>Send Recovery</Label>
202
+ </Button>
203
+ {:else}
204
+ <Button
205
+ onclick={preventDefault(stopPropagation(recover))}
206
+ disabled={loading}
207
+ {...prefixFilter(restProps, 'resetPasswordButton$')}
208
+ >
209
+ <Label>Reset Password</Label>
210
+ </Button>
211
+ {/if}
212
+ {/if}
213
+ </Actions>
214
+ </Dialog>
215
+ {/if}
216
+
217
+ <script lang="ts">
218
+ import type { ComponentProps } from 'svelte';
219
+ import { onMount } from 'svelte';
220
+ import type { Writable } from 'svelte/store';
221
+ import { writable } from 'svelte/store';
222
+ import CircularProgress from '@smui/circular-progress';
223
+ import Dialog, { Title, Content, Actions } from '@smui/dialog';
224
+ import Textfield from '@smui/textfield';
225
+ import Button, { Label } from '@smui/button';
226
+ import FormField from '@smui/form-field';
227
+ import Radio from '@smui/radio';
228
+ import type { ActionArray } from '@smui/common/internal';
229
+ import { exclude, prefixFilter } from '@smui/common/internal';
230
+ import { preventDefault, stopPropagation } from '@smui/common/events';
231
+ import type { SmuiElementPropMap } from '@smui/common';
232
+ import type {
233
+ User as UserClass,
234
+ ClientConfig,
235
+ } from '@nymphjs/tilmeld-client';
236
+
237
+ type OwnProps = {
238
+ /**
239
+ * An array of Action or [Action, ActionProps] to be applied to the element.
240
+ */
241
+ use?: ActionArray;
242
+ /**
243
+ * Whether the dialog is open.
244
+ */
245
+ open?: boolean;
246
+ /**
247
+ * The title of the dialog.
248
+ */
249
+ title?: string;
250
+ /**
251
+ * A writable store of the Nymph client config.
252
+ *
253
+ * It will be retrieved from the server if not provided.
254
+ */
255
+ clientConfig?: Writable<ClientConfig | undefined>;
256
+ /**
257
+ * The User class from Nymph.
258
+ */
259
+ User: typeof UserClass;
260
+ /**
261
+ * Give focus to the account box when the form is ready.
262
+ */
263
+ autofocus?: boolean;
264
+ /**
265
+ * The type of recovery being requested.
266
+ *
267
+ * User provided. You can bind to it if you need to.
268
+ */
269
+ recoveryType?: 'username' | 'password';
270
+ /**
271
+ * User provided. You can bind to it if you need to.
272
+ */
273
+ account?: string;
274
+ /**
275
+ * User provided. You can bind to it if you need to.
276
+ */
277
+ secret?: string;
278
+ /**
279
+ * User provided. You can bind to it if you need to.
280
+ */
281
+ password?: string;
282
+ /**
283
+ * User provided. You can bind to it if you need to.
284
+ */
285
+ password2?: string;
286
+ };
287
+ let {
288
+ use = [],
289
+ open = $bindable(false),
290
+ title = 'Recover Your Account',
291
+ clientConfig = $bindable(writable(false as unknown as undefined)),
292
+ User,
293
+ autofocus = true,
294
+ recoveryType = $bindable('password'),
295
+ account = $bindable(''),
296
+ secret = $bindable(''),
297
+ password = $bindable(''),
298
+ password2 = $bindable(''),
299
+ ...restProps
300
+ }: OwnProps & {
301
+ [
302
+ k in keyof ComponentProps<typeof Radio> as `recoveryTypePassword\$${k}`
303
+ ]?: ComponentProps<typeof Radio>[k];
304
+ } & {
305
+ [
306
+ k in keyof ComponentProps<typeof Radio> as `recoveryTypeUsername\$${k}`
307
+ ]?: ComponentProps<typeof Radio>[k];
308
+ } & {
309
+ [
310
+ k in keyof ComponentProps<typeof Textfield> as `account\$${k}`
311
+ ]?: ComponentProps<typeof Textfield>[k];
312
+ } & {
313
+ [
314
+ k in keyof SmuiElementPropMap['a'] as `alreadyGotCodeLink\$${k}`
315
+ ]?: SmuiElementPropMap['a'][k];
316
+ } & {
317
+ [
318
+ k in keyof SmuiElementPropMap['p'] as `codeSentMessage\$${k}`
319
+ ]?: SmuiElementPropMap['p'][k];
320
+ } & {
321
+ [
322
+ k in keyof ComponentProps<typeof Textfield> as `recoveryCode\$${k}`
323
+ ]?: ComponentProps<typeof Textfield>[k];
324
+ } & {
325
+ [
326
+ k in keyof ComponentProps<typeof Textfield> as `password\$${k}`
327
+ ]?: ComponentProps<typeof Textfield>[k];
328
+ } & {
329
+ [
330
+ k in keyof ComponentProps<typeof Textfield> as `password2\$${k}`
331
+ ]?: ComponentProps<typeof Textfield>[k];
332
+ } & {
333
+ [
334
+ k in keyof SmuiElementPropMap['a'] as `needCodeLink\$${k}`
335
+ ]?: SmuiElementPropMap['a'][k];
336
+ } & {
337
+ [
338
+ k in keyof ComponentProps<typeof CircularProgress> as `progress\$${k}`
339
+ ]?: ComponentProps<typeof CircularProgress>[k];
340
+ } & {
341
+ [
342
+ k in keyof ComponentProps<
343
+ typeof Button<undefined, 'button'>
344
+ > as `sendCodeButton\$${k}`
345
+ ]?: ComponentProps<typeof Button<undefined, 'button'>>[k];
346
+ } & {
347
+ [
348
+ k in keyof ComponentProps<
349
+ typeof Button<undefined, 'button'>
350
+ > as `resetPasswordButton\$${k}`
351
+ ]?: ComponentProps<typeof Button<undefined, 'button'>>[k];
352
+ } = $props();
353
+
354
+ let loading = $state(false);
355
+ let hasSentSecret: number | boolean = $state(false);
356
+ let accountElem: Textfield | undefined = $state();
357
+ let failureMessage: string | undefined = $state();
358
+ let successRecoveredMessage: string | undefined = $state();
359
+
360
+ $effect(() => {
361
+ if (open && autofocus && accountElem) {
362
+ accountElem.focus();
363
+ }
364
+ });
365
+
366
+ onMount(async () => {
367
+ if ($clientConfig === (false as unknown as undefined)) {
368
+ $clientConfig = undefined;
369
+ $clientConfig = await User.getClientConfig();
370
+ }
371
+ });
372
+
373
+ async function sendRecovery() {
374
+ if (account === '') {
375
+ failureMessage =
376
+ 'You need to enter ' +
377
+ ($clientConfig?.emailUsernames || recoveryType === 'username'
378
+ ? 'an email address'
379
+ : 'a username') +
380
+ '.';
381
+ return;
382
+ }
383
+
384
+ failureMessage = undefined;
385
+ loading = true;
386
+
387
+ try {
388
+ const data = await User.sendRecovery({
389
+ recoveryType,
390
+ account,
391
+ });
392
+ if (!data.result) {
393
+ failureMessage = data.message;
394
+ } else {
395
+ if (recoveryType === 'username') {
396
+ successRecoveredMessage = data.message;
397
+ } else if (recoveryType === 'password') {
398
+ hasSentSecret = true;
399
+ }
400
+ }
401
+ } catch (e: any) {
402
+ failureMessage = e?.message;
403
+ }
404
+ loading = false;
405
+ }
406
+
407
+ async function recover() {
408
+ if (account === '') {
409
+ failureMessage =
410
+ 'You need to enter ' +
411
+ ($clientConfig?.emailUsernames || recoveryType === 'username'
412
+ ? 'an email address'
413
+ : 'a username') +
414
+ '.';
415
+ return;
416
+ }
417
+ if (password !== password2) {
418
+ failureMessage = 'Your passwords do not match.';
419
+ return;
420
+ }
421
+ if (password === '') {
422
+ failureMessage = 'You need to enter a password.';
423
+ return;
424
+ }
425
+
426
+ failureMessage = undefined;
427
+ loading = true;
428
+ try {
429
+ const data = await User.recover({
430
+ username: account,
431
+ secret,
432
+ password,
433
+ });
434
+ if (!data.result) {
435
+ failureMessage = data.message;
436
+ } else {
437
+ successRecoveredMessage = data.message;
438
+ }
439
+ } catch (e: any) {
440
+ failureMessage = e?.message;
441
+ }
442
+ loading = false;
443
+ }
444
+ </script>
445
+
446
+ <style>
447
+ :global(.mdc-dialog .mdc-dialog__surface.tilmeld-recover-dialog-surface) {
448
+ width: 540px;
449
+ max-width: calc(100vw - 32px);
450
+ }
451
+ .tilmeld-recover-action {
452
+ margin-top: 1em;
453
+ }
454
+ .tilmeld-recover-failure {
455
+ margin-top: 1em;
456
+ color: var(--mdc-theme-error, #f00);
457
+ }
458
+ .tilmeld-recover-loading {
459
+ display: flex;
460
+ justify-content: center;
461
+ align-items: center;
462
+ }
463
+ </style>
@@ -0,0 +1,236 @@
1
+ <svelte:options runes />
2
+
3
+ {#if $user != null}
4
+ <Dialog
5
+ {use}
6
+ bind:open
7
+ aria-labelledby="tilmeld-revoke-tokens-title"
8
+ aria-describedby="tilmeld-revoke-tokens-content"
9
+ surface$class="tilmeld-revoke-tokens-dialog-surface"
10
+ {...exclude(restProps, [
11
+ 'password$',
12
+ 'closeButton$',
13
+ 'saveButton$',
14
+ 'progress$',
15
+ ])}
16
+ >
17
+ <!-- Title cannot contain leading whitespace due to mdc-typography-baseline-top() -->
18
+ <Title id="tilmeld-revoke-tokens-title">{title}</Title>
19
+ <Content id="tilmeld-revoke-tokens-content">
20
+ <div>
21
+ {description}
22
+ </div>
23
+
24
+ <div style="margin-top: 1em;">
25
+ <Textfield
26
+ bind:value={password}
27
+ label="Password"
28
+ type="password"
29
+ style="width: 100%;"
30
+ input$autocomplete="current-password"
31
+ {...prefixFilter(restProps, 'password$')}
32
+ />
33
+ </div>
34
+
35
+ {@render additional?.()}
36
+
37
+ {#if failureMessage}
38
+ <div class="tilmeld-revoke-tokens-failure">
39
+ {failureMessage}
40
+ </div>
41
+ {/if}
42
+
43
+ {#if loading}
44
+ <div class="tilmeld-revoke-tokens-loading">
45
+ <CircularProgress
46
+ style="height: 24px; width: 24px;"
47
+ indeterminate
48
+ {...prefixFilter(restProps, 'progress$')}
49
+ />
50
+ </div>
51
+ {/if}
52
+ </Content>
53
+ <Actions>
54
+ <Button disabled={loading} {...prefixFilter(restProps, 'closeButton$')}>
55
+ <Label>Close</Label>
56
+ </Button>
57
+ <Button
58
+ onclick={preventDefault(stopPropagation(revokeTokens))}
59
+ disabled={loading}
60
+ {...prefixFilter(restProps, 'saveButton$')}
61
+ >
62
+ <Label>Log Out Other Sessions</Label>
63
+ </Button>
64
+ </Actions>
65
+ </Dialog>
66
+ {/if}
67
+
68
+ <script lang="ts">
69
+ import type { ComponentProps, Snippet } from 'svelte';
70
+ import { onMount, onDestroy } from 'svelte';
71
+ import type { Writable } from 'svelte/store';
72
+ import { writable } from 'svelte/store';
73
+ import CircularProgress from '@smui/circular-progress';
74
+ import Dialog, { Title, Content, Actions } from '@smui/dialog';
75
+ import Textfield from '@smui/textfield';
76
+ import Button, { Label } from '@smui/button';
77
+ import type { ActionArray } from '@smui/common/internal';
78
+ import { exclude, prefixFilter } from '@smui/common/internal';
79
+ import { preventDefault, stopPropagation } from '@smui/common/events';
80
+ import type { CurrentUserData } from '@nymphjs/tilmeld-client';
81
+ import type { User as UserClass } from '@nymphjs/tilmeld-client';
82
+
83
+ type OwnProps = {
84
+ /**
85
+ * An array of Action or [Action, ActionProps] to be applied to the element.
86
+ */
87
+ use?: ActionArray;
88
+ /**
89
+ * Whether the dialog is open.
90
+ */
91
+ open?: boolean;
92
+ /**
93
+ * The title of the dialog.
94
+ */
95
+ title?: string;
96
+ /**
97
+ * The description of the action given to the user.
98
+ */
99
+ description?: string;
100
+ /**
101
+ * The User class from Nymph.
102
+ */
103
+ User: typeof UserClass;
104
+ /**
105
+ * A writable store of the current user.
106
+ *
107
+ * It will be retrieved from the server if not provided.
108
+ */
109
+ user?: Writable<(UserClass & CurrentUserData) | null | undefined>;
110
+ /**
111
+ * User provided. You can bind to it if you need to.
112
+ */
113
+ password?: string;
114
+
115
+ /**
116
+ * A spot for additional content.
117
+ */
118
+ additional?: Snippet;
119
+ };
120
+ let {
121
+ use = [],
122
+ open = $bindable(false),
123
+ title = 'Log Out of Other Sessions',
124
+ description = 'This will log you out of all other sessions. You can use this if you think your account has been compromised. You will need to log in again on your other devices.',
125
+ User,
126
+ user = $bindable(writable(false as unknown as undefined)),
127
+ password = $bindable(''),
128
+ additional,
129
+ ...restProps
130
+ }: OwnProps & {
131
+ [
132
+ k in keyof ComponentProps<typeof Textfield> as `password\$${k}`
133
+ ]?: ComponentProps<typeof Textfield>[k];
134
+ } & {
135
+ [
136
+ k in keyof ComponentProps<typeof CircularProgress> as `progress\$${k}`
137
+ ]?: ComponentProps<typeof CircularProgress>[k];
138
+ } & {
139
+ [
140
+ k in keyof ComponentProps<
141
+ typeof Button<undefined, 'button'>
142
+ > as `closeButton\$${k}`
143
+ ]?: ComponentProps<typeof Button<undefined, 'button'>>[k];
144
+ } & {
145
+ [
146
+ k in keyof ComponentProps<
147
+ typeof Button<undefined, 'button'>
148
+ > as `saveButton\$${k}`
149
+ ]?: ComponentProps<typeof Button<undefined, 'button'>>[k];
150
+ } = $props();
151
+
152
+ let loading = $state(false);
153
+ let failureMessage: string | undefined = $state();
154
+
155
+ $effect(() => {
156
+ if (!open) {
157
+ loading = false;
158
+ failureMessage = undefined;
159
+ password = '';
160
+ }
161
+ });
162
+
163
+ const onLogin = (currentUser: UserClass & CurrentUserData) => {
164
+ $user = currentUser;
165
+ };
166
+ const onLogout = () => {
167
+ $user = null;
168
+ };
169
+
170
+ onMount(async () => {
171
+ User.on('login', onLogin);
172
+ User.on('logout', onLogout);
173
+ if ($user === (false as unknown as undefined)) {
174
+ $user = undefined;
175
+ $user = await User.current();
176
+ }
177
+ });
178
+
179
+ onDestroy(() => {
180
+ User.off('login', onLogin);
181
+ User.off('logout', onLogout);
182
+ });
183
+
184
+ async function revokeTokens() {
185
+ if (password === '') {
186
+ failureMessage = 'You need to enter your password';
187
+ return;
188
+ }
189
+
190
+ failureMessage = undefined;
191
+ loading = true;
192
+
193
+ // Get the current user again, in case their data has changed.
194
+ $user = await User.current();
195
+
196
+ if ($user == null) {
197
+ failureMessage = 'You must be logged in.';
198
+ loading = false;
199
+ return;
200
+ }
201
+
202
+ try {
203
+ // Revoke the current tokens.
204
+ const data = await $user.$revokeCurrentTokens({
205
+ password,
206
+ });
207
+
208
+ if (!data.result) {
209
+ failureMessage = data.message;
210
+ } else {
211
+ open = false;
212
+ }
213
+ } catch (e: any) {
214
+ failureMessage = e?.message;
215
+ }
216
+ loading = false;
217
+ }
218
+ </script>
219
+
220
+ <style>
221
+ :global(
222
+ .mdc-dialog .mdc-dialog__surface.tilmeld-revoke-tokens-dialog-surface
223
+ ) {
224
+ width: 360px;
225
+ max-width: calc(100vw - 32px);
226
+ }
227
+ .tilmeld-revoke-tokens-failure {
228
+ margin-top: 1em;
229
+ color: var(--mdc-theme-error, #f00);
230
+ }
231
+ .tilmeld-revoke-tokens-loading {
232
+ display: flex;
233
+ justify-content: center;
234
+ align-items: center;
235
+ }
236
+ </style>