@nymphjs/tilmeld-setup 1.0.0-beta.13 → 1.0.0-beta.131

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,1193 @@
1
+ {#if $clientConfig == null || $user == null || loading}
2
+ <section>
3
+ <div style="display: flex; justify-content: center; align-items: center;">
4
+ <CircularProgress style="height: 45px; width: 45px;" indeterminate />
5
+ </div>
6
+ </section>
7
+ {:else}
8
+ <div style="display: flex; align-items: center; padding: 12px;">
9
+ <IconButton title="Back" onclick={() => pop()}>
10
+ <Icon tag="svg" viewBox="0 0 24 24">
11
+ <path fill="currentColor" d={mdiArrowLeft} />
12
+ </Icon>
13
+ </IconButton>
14
+ <h2 style="margin: 0px 12px 0px;" class="mdc-typography--headline5">
15
+ Editing {$entity.guid
16
+ ? $entity.$is($user)
17
+ ? 'Yourself'
18
+ : ($entity.name ?? $entity.username)
19
+ : 'New User'}
20
+ </h2>
21
+ </div>
22
+
23
+ <TabBar
24
+ tabs={['General', 'Groups', 'Abilities', 'Security']}
25
+ bind:active={activeTab}
26
+ >
27
+ {#snippet tab(tab)}
28
+ <Tab {tab}>
29
+ <Label>{tab}</Label>
30
+ </Tab>
31
+ {/snippet}
32
+ </TabBar>
33
+
34
+ <section>
35
+ {#if activeTab === 'General'}
36
+ <LayoutGrid style="padding: 0;">
37
+ <LayoutCell span={4}>
38
+ <div class="mdc-typography--headline6">GUID</div>
39
+ <code>{$entity.guid}</code>
40
+ </LayoutCell>
41
+ <LayoutCell span={4}>
42
+ <FormField>
43
+ <Checkbox bind:checked={$entity.enabled} />
44
+ {#snippet label()}
45
+ Enabled (Able to log in)
46
+ {/snippet}
47
+ </FormField>
48
+ </LayoutCell>
49
+ <LayoutCell span={4} style="text-align: end;">
50
+ <a href="https://en.gravatar.com/" target="_blank" rel="noreferrer">
51
+ <img src={avatar} alt="Avatar" title="Avatar by Gravatar" />
52
+ </a>
53
+ </LayoutCell>
54
+ {#if !$clientConfig.emailUsernames}
55
+ <LayoutCell
56
+ span={$clientConfig.userFields.includes('email') ? 6 : 12}
57
+ >
58
+ <Textfield
59
+ bind:value={$entity.username}
60
+ label="Username"
61
+ type="text"
62
+ style="width: 100%;"
63
+ helperLine$style="width: 100%;"
64
+ invalid={usernameVerified === false}
65
+ input$autocomplete="off"
66
+ input$autocapitalize="off"
67
+ input$spellcheck="false"
68
+ >
69
+ {#snippet helper()}
70
+ <HelperText persistent>
71
+ {usernameVerifiedMessage ?? ''}
72
+ </HelperText>
73
+ {/snippet}
74
+ </Textfield>
75
+ </LayoutCell>
76
+ {/if}
77
+ {#if $clientConfig.userFields.includes('email')}
78
+ <LayoutCell span={$clientConfig.emailUsernames ? 12 : 6}>
79
+ <Textfield
80
+ bind:value={$entity.email}
81
+ label="Email"
82
+ type="email"
83
+ style="width: 100%;"
84
+ helperLine$style="width: 100%;"
85
+ invalid={emailVerified === false}
86
+ input$autocomplete="off"
87
+ input$autocapitalize="off"
88
+ input$spellcheck="false"
89
+ >
90
+ {#snippet helper()}
91
+ <HelperText persistent>
92
+ {emailVerifiedMessage ?? ''}
93
+ </HelperText>
94
+ {/snippet}
95
+ </Textfield>
96
+ </LayoutCell>
97
+ {/if}
98
+ {#if $clientConfig.userFields.includes('name')}
99
+ <LayoutCell span={4}>
100
+ <Textfield
101
+ bind:value={$entity.nameFirst}
102
+ label="First Name"
103
+ type="text"
104
+ style="width: 100%;"
105
+ input$autocomplete="off"
106
+ />
107
+ </LayoutCell>
108
+ <LayoutCell span={4}>
109
+ <Textfield
110
+ bind:value={$entity.nameMiddle}
111
+ label="Middle Name"
112
+ type="text"
113
+ style="width: 100%;"
114
+ input$autocomplete="off"
115
+ />
116
+ </LayoutCell>
117
+ <LayoutCell span={4}>
118
+ <Textfield
119
+ bind:value={$entity.nameLast}
120
+ label="Last Name"
121
+ type="text"
122
+ style="width: 100%;"
123
+ input$autocomplete="off"
124
+ />
125
+ </LayoutCell>
126
+ {/if}
127
+ <LayoutCell span={$clientConfig.userFields.includes('phone') ? 8 : 12}>
128
+ <Textfield
129
+ bind:value={$entity.avatar}
130
+ label="Avatar"
131
+ type="text"
132
+ style="width: 100%;"
133
+ input$autocomplete="off"
134
+ />
135
+ </LayoutCell>
136
+ {#if $clientConfig.userFields.includes('phone')}
137
+ <LayoutCell span={4}>
138
+ <Textfield
139
+ bind:value={$entity.phone}
140
+ label="Phone"
141
+ type="tel"
142
+ style="width: 100%;"
143
+ input$autocomplete="off"
144
+ />
145
+ </LayoutCell>
146
+ {/if}
147
+ <LayoutCell span={6}>
148
+ <Textfield
149
+ bind:value={$entity.passwordTemp}
150
+ label={`${$entity.guid ? 'Update ' : ''}Password`}
151
+ type="password"
152
+ style="width: 100%;"
153
+ input$autocomplete="off"
154
+ />
155
+ </LayoutCell>
156
+ <LayoutCell span={6}>
157
+ <Textfield
158
+ bind:value={passwordVerify}
159
+ label="Repeat Password"
160
+ type="password"
161
+ style="width: 100%;"
162
+ invalid={passwordVerified === false}
163
+ input$autocomplete="off"
164
+ onblur={doVerifyPassword}
165
+ />
166
+ </LayoutCell>
167
+ </LayoutGrid>
168
+ {/if}
169
+
170
+ {#if activeTab === 'Groups'}
171
+ {#if $entity.guid == null}
172
+ <p style="margin-top: 0;">
173
+ When you leave primary group empty, if Nymph is configured to generate
174
+ primary groups, one will be generated for this new user. Otherwise,
175
+ Nymph will assign the default primary group. Likewise, when you leave
176
+ secondary groups empty, Nymph will assign the default secondary
177
+ groups.
178
+ </p>
179
+ {/if}
180
+
181
+ <h5 style={$entity.guid == null ? '' : 'margin-top: 0;'}>
182
+ Primary Group
183
+ </h5>
184
+
185
+ <Paper
186
+ style="display: flex; justify-content: space-between; align-items: center;"
187
+ >
188
+ {#if !$entity.group}
189
+ No primary group
190
+ {:else}
191
+ <a
192
+ href="/groups/edit/{encodeURIComponent($entity.group.guid || '')}"
193
+ use:link
194
+ >{$clientConfig.userFields.includes('name')
195
+ ? $entity.group.name + ' (' + $entity.group.groupname + ')'
196
+ : $entity.group.groupname}</a
197
+ >
198
+
199
+ <IconButton
200
+ onclick={() => {
201
+ delete $entity.group;
202
+ $entity = $entity;
203
+ }}
204
+ >
205
+ <Icon tag="svg" viewBox="0 0 24 24">
206
+ <path fill="currentColor" d={mdiMinus} />
207
+ </Icon>
208
+ </IconButton>
209
+ {/if}
210
+ </Paper>
211
+
212
+ <h6>Change Primary Group</h6>
213
+
214
+ <div class="solo-search-container solo-container">
215
+ <Paper class="solo-paper" elevation={1}>
216
+ <Icon class="solo-icon" tag="svg" viewBox="0 0 24 24">
217
+ <path fill="currentColor" d={mdiMagnify} />
218
+ </Icon>
219
+ <Input
220
+ bind:value={primaryGroupSearch}
221
+ onkeydown={primaryGroupSearchKeyDown}
222
+ placeholder="Primary Group Search"
223
+ class="solo-input"
224
+ />
225
+ </Paper>
226
+ <IconButton
227
+ onclick={searchPrimaryGroups}
228
+ disabled={primaryGroupSearch === ''}
229
+ class="solo-fab"
230
+ title="Search"
231
+ >
232
+ <Icon tag="svg" viewBox="0 0 24 24">
233
+ <path fill="currentColor" d={mdiArrowRight} />
234
+ </Icon>
235
+ </IconButton>
236
+ </div>
237
+
238
+ {#if primaryGroupsSearching}
239
+ <div
240
+ style="display: flex; justify-content: center; align-items: center;"
241
+ >
242
+ <CircularProgress style="height: 32px; width: 32px;" indeterminate />
243
+ </div>
244
+ {:else if primaryGroups != null}
245
+ <DataTable table$aria-label="Primary group list" style="width: 100%;">
246
+ <Head>
247
+ <Row>
248
+ {#if !$clientConfig.emailUsernames}
249
+ <Cell>Groupname</Cell>
250
+ {/if}
251
+ {#if $clientConfig.userFields.includes('name')}
252
+ <Cell>Name</Cell>
253
+ {/if}
254
+ {#if $clientConfig.userFields.includes('email')}
255
+ <Cell>Email</Cell>
256
+ {/if}
257
+ <Cell>Enabled</Cell>
258
+ </Row>
259
+ </Head>
260
+ <Body>
261
+ <!-- Purposefully not making these links. -->
262
+ {#each primaryGroups as curEntity (curEntity.guid)}
263
+ <Row
264
+ onclick={() => ($entity.group = curEntity)}
265
+ style="cursor: pointer;"
266
+ >
267
+ {#if !$clientConfig.emailUsernames}
268
+ <Cell>{curEntity.groupname}</Cell>
269
+ {/if}
270
+ {#if $clientConfig.userFields.includes('name')}
271
+ <Cell>{curEntity.name}</Cell>
272
+ {/if}
273
+ {#if $clientConfig.userFields.includes('email')}
274
+ <Cell>{curEntity.email}</Cell>
275
+ {/if}
276
+ <Cell>{curEntity.enabled ? 'Yes' : 'No'}</Cell>
277
+ </Row>
278
+ {/each}
279
+ </Body>
280
+ </DataTable>
281
+ {/if}
282
+
283
+ <h5>Secondary Groups</h5>
284
+
285
+ <DataTable
286
+ table$aria-label="Current secondary groups"
287
+ style="width: 100%;"
288
+ >
289
+ <Head>
290
+ <Row>
291
+ {#if !$clientConfig.emailUsernames}
292
+ <Cell>Groupname</Cell>
293
+ {/if}
294
+ {#if $clientConfig.userFields.includes('name')}
295
+ <Cell>Name</Cell>
296
+ {/if}
297
+ {#if $clientConfig.userFields.includes('email')}
298
+ <Cell>Email</Cell>
299
+ {/if}
300
+ <Cell>Enabled</Cell>
301
+ <Cell>Remove</Cell>
302
+ </Row>
303
+ </Head>
304
+ <Body>
305
+ {#each $entity.groups || [] as curEntity, index (curEntity.guid)}
306
+ <Row>
307
+ {#if !$clientConfig.emailUsernames}
308
+ <Cell
309
+ ><a
310
+ href="/groups/edit/{encodeURIComponent(
311
+ curEntity.guid || '',
312
+ )}"
313
+ use:link>{curEntity.groupname}</a
314
+ ></Cell
315
+ >
316
+ {/if}
317
+ {#if $clientConfig.userFields.includes('name')}
318
+ <Cell
319
+ ><a
320
+ href="/groups/edit/{encodeURIComponent(
321
+ curEntity.guid || '',
322
+ )}"
323
+ use:link>{curEntity.name}</a
324
+ ></Cell
325
+ >
326
+ {/if}
327
+ {#if $clientConfig.userFields.includes('email')}
328
+ <Cell
329
+ ><a
330
+ href="/groups/edit/{encodeURIComponent(
331
+ curEntity.guid || '',
332
+ )}"
333
+ use:link>{curEntity.email}</a
334
+ ></Cell
335
+ >
336
+ {/if}
337
+ <Cell>{curEntity.enabled ? 'Yes' : 'No'}</Cell>
338
+ <Cell>
339
+ <IconButton
340
+ onclick={() => {
341
+ $entity.groups?.splice(index, 1);
342
+ $entity = $entity;
343
+ }}
344
+ >
345
+ <Icon tag="svg" viewBox="0 0 24 24">
346
+ <path fill="currentColor" d={mdiMinus} />
347
+ </Icon>
348
+ </IconButton>
349
+ </Cell>
350
+ </Row>
351
+ {:else}
352
+ <Row>
353
+ <Cell
354
+ colspan={2 +
355
+ (!$clientConfig.emailUsernames ? 1 : 0) +
356
+ ($clientConfig.userFields.includes('name') ? 1 : 0) +
357
+ ($clientConfig.userFields.includes('email') ? 1 : 0)}
358
+ >
359
+ No secondary groups
360
+ </Cell>
361
+ </Row>
362
+ {/each}
363
+ </Body>
364
+ </DataTable>
365
+
366
+ <h6>Add Secondary Groups</h6>
367
+
368
+ <div class="solo-search-container solo-container">
369
+ <Paper class="solo-paper" elevation={1}>
370
+ <Icon class="solo-icon" tag="svg" viewBox="0 0 24 24">
371
+ <path fill="currentColor" d={mdiMagnify} />
372
+ </Icon>
373
+ <Input
374
+ bind:value={secondaryGroupSearch}
375
+ onkeydown={secondaryGroupSearchKeyDown}
376
+ placeholder="Secondary Group Search"
377
+ class="solo-input"
378
+ />
379
+ </Paper>
380
+ <IconButton
381
+ onclick={searchSecondaryGroups}
382
+ disabled={secondaryGroupSearch === ''}
383
+ class="solo-fab"
384
+ title="Search"
385
+ >
386
+ <Icon tag="svg" viewBox="0 0 24 24">
387
+ <path fill="currentColor" d={mdiArrowRight} />
388
+ </Icon>
389
+ </IconButton>
390
+ </div>
391
+
392
+ {#if secondaryGroupsSearching}
393
+ <div
394
+ style="display: flex; justify-content: center; align-items: center;"
395
+ >
396
+ <CircularProgress style="height: 32px; width: 32px;" indeterminate />
397
+ </div>
398
+ {:else if secondaryGroups != null}
399
+ <DataTable table$aria-label="Secondary group list" style="width: 100%;">
400
+ <Head>
401
+ <Row>
402
+ {#if !$clientConfig.emailUsernames}
403
+ <Cell>Groupname</Cell>
404
+ {/if}
405
+ {#if $clientConfig.userFields.includes('name')}
406
+ <Cell>Name</Cell>
407
+ {/if}
408
+ {#if $clientConfig.userFields.includes('email')}
409
+ <Cell>Email</Cell>
410
+ {/if}
411
+ <Cell>Enabled</Cell>
412
+ </Row>
413
+ </Head>
414
+ <Body>
415
+ <!-- Purposefully not making these links. -->
416
+ {#each secondaryGroups as curEntity, index (curEntity.guid)}
417
+ <Row
418
+ onclick={() => {
419
+ $entity.groups?.push(curEntity);
420
+ secondaryGroups?.splice(index, 1);
421
+ $entity = $entity;
422
+ }}
423
+ style="cursor: pointer;"
424
+ >
425
+ {#if !$clientConfig.emailUsernames}
426
+ <Cell>{curEntity.groupname}</Cell>
427
+ {/if}
428
+ {#if $clientConfig.userFields.includes('name')}
429
+ <Cell>{curEntity.name}</Cell>
430
+ {/if}
431
+ {#if $clientConfig.userFields.includes('email')}
432
+ <Cell>{curEntity.email}</Cell>
433
+ {/if}
434
+ <Cell>{curEntity.enabled ? 'Yes' : 'No'}</Cell>
435
+ </Row>
436
+ {/each}
437
+ </Body>
438
+ </DataTable>
439
+ {/if}
440
+ {/if}
441
+
442
+ {#if activeTab === 'Abilities'}
443
+ <h5 style="margin-top: 0;">Abilities</h5>
444
+
445
+ <List nonInteractive>
446
+ {#each $entity.abilities || [] as ability, index (ability)}
447
+ <Item>
448
+ <Text>
449
+ {ability}
450
+ </Text>
451
+ <Meta>
452
+ <IconButton
453
+ onclick={() => {
454
+ $entity.abilities?.splice(index, 1);
455
+ $entity = $entity;
456
+ }}
457
+ >
458
+ <Icon tag="svg" viewBox="0 0 24 24">
459
+ <path fill="currentColor" d={mdiMinus} />
460
+ </Icon>
461
+ </IconButton>
462
+ </Meta>
463
+ </Item>
464
+ {:else}
465
+ <Item>
466
+ <Text>No abilities</Text>
467
+ </Item>
468
+ {/each}
469
+ </List>
470
+
471
+ <h6>Add Ability</h6>
472
+
473
+ <div style="display: flex; align-items: center; flex-wrap: wrap;">
474
+ <Textfield
475
+ bind:value={ability}
476
+ label="Ability"
477
+ type="text"
478
+ style="width: 250px; max-width: 100%;"
479
+ onkeydown={abilityKeyDown}
480
+ />
481
+ <IconButton onclick={addAbility}>
482
+ <Icon tag="svg" viewBox="0 0 24 24">
483
+ <path fill="currentColor" d={mdiPlus} />
484
+ </Icon>
485
+ </IconButton>
486
+ {#if sysAdmin}
487
+ <Button
488
+ onclick={addSystemAdminAbility}
489
+ title="System Admins have all abilities. Gatekeeper checks always return true."
490
+ >
491
+ <Label>System Admin</Label>
492
+ </Button>
493
+ <Button
494
+ onclick={addTilmeldAdminAbility}
495
+ title="Tilmeld Admins have the ability to modify, create, and delete users and groups, and grant and revoke abilities."
496
+ >
497
+ <Label>Tilmeld Admin</Label>
498
+ </Button>
499
+ <Button
500
+ onclick={addTilmeldSwitchAbility}
501
+ title="The switch user ability lets a user log in as another non-admin user without needing their password."
502
+ >
503
+ <Label>Switch User</Label>
504
+ </Button>
505
+ {/if}
506
+ </div>
507
+
508
+ <h6>Inherit Abilities</h6>
509
+
510
+ <div>
511
+ <FormField>
512
+ <Checkbox bind:checked={$entity.inheritAbilities} />
513
+ {#snippet label()}
514
+ Additionally, inherit the abilities of the group(s) this user
515
+ belongs to.
516
+ {/snippet}
517
+ </FormField>
518
+ </div>
519
+ {/if}
520
+
521
+ {#if activeTab === 'Security'}
522
+ <LayoutGrid style="padding: 0;">
523
+ {#if $clientConfig.userFields.includes('email')}
524
+ <LayoutCell span={12}>
525
+ <h5>Verification</h5>
526
+ <p>
527
+ The email verification secret is the code emailed to the user to
528
+ verify their address when they first sign up.
529
+ </p>
530
+ </LayoutCell>
531
+ <LayoutCell span={12}>
532
+ <Textfield
533
+ bind:value={$entity.secret}
534
+ label="Email Verification Secret"
535
+ type="text"
536
+ style="width: 100%;"
537
+ input$autocomplete="off"
538
+ />
539
+ </LayoutCell>
540
+ <LayoutCell span={12}>
541
+ <h5>Account Recovery</h5>
542
+ <p>
543
+ The account recovery secret is the code emailed to the user to
544
+ allow them to change their password and recover their account. The
545
+ date is used to determine if the code has expired.
546
+ </p>
547
+ </LayoutCell>
548
+ <LayoutCell span={6}>
549
+ <Textfield
550
+ bind:value={$entity.recoverSecret}
551
+ label="Account Recovery Secret"
552
+ type="text"
553
+ style="width: 100%;"
554
+ input$autocomplete="off"
555
+ />
556
+ </LayoutCell>
557
+ <LayoutCell span={6}>
558
+ <Textfield
559
+ bind:value={$entity.recoverSecretDate}
560
+ label="Account Recovery Date (Timestamp)"
561
+ type="number"
562
+ style="width: 100%;"
563
+ input$autocomplete="off"
564
+ >
565
+ {#snippet helper()}
566
+ <HelperText persistent>
567
+ {$entity.recoverSecretDate === 0 ||
568
+ $entity.recoverSecretDate == null
569
+ ? 'Unset'
570
+ : new Date($entity.recoverSecretDate).toLocaleString()}
571
+ </HelperText>
572
+ {/snippet}
573
+ </Textfield>
574
+ </LayoutCell>
575
+ <LayoutCell span={12}>
576
+ <h5>Email Change</h5>
577
+ <p>
578
+ An email change uses all of the following properties. The email
579
+ change date is used to rate limit email changes and to allow the
580
+ user to cancel the change within the rate limit time. The new
581
+ secret is emailed to the new address, and when the user clicks the
582
+ link, that email address is set for their account. The cancel
583
+ secret is emailed to the old address and will reset the user's
584
+ email to the cancel address if the link is clicked in time.
585
+ </p>
586
+ </LayoutCell>
587
+ <LayoutCell span={12}>
588
+ <Textfield
589
+ bind:value={$entity.emailChangeDate}
590
+ label="Email Change Date (Timestamp)"
591
+ type="number"
592
+ style="width: 100%;"
593
+ input$autocomplete="off"
594
+ >
595
+ {#snippet helper()}
596
+ <HelperText persistent>
597
+ {$entity.emailChangeDate === 0 ||
598
+ $entity.emailChangeDate == null
599
+ ? 'Unset'
600
+ : new Date($entity.emailChangeDate).toLocaleString()}
601
+ </HelperText>
602
+ {/snippet}
603
+ </Textfield>
604
+ </LayoutCell>
605
+ <LayoutCell span={6}>
606
+ <Textfield
607
+ bind:value={$entity.newEmailSecret}
608
+ label="New Email Verification Secret"
609
+ type="text"
610
+ style="width: 100%;"
611
+ input$autocomplete="off"
612
+ />
613
+ </LayoutCell>
614
+ <LayoutCell span={6}>
615
+ <Textfield
616
+ bind:value={$entity.newEmailAddress}
617
+ label="New Email Address"
618
+ type="email"
619
+ style="width: 100%;"
620
+ input$autocomplete="off"
621
+ />
622
+ </LayoutCell>
623
+ <LayoutCell span={6}>
624
+ <Textfield
625
+ bind:value={$entity.cancelEmailSecret}
626
+ label="Cancel Email Verification Secret"
627
+ type="text"
628
+ style="width: 100%;"
629
+ input$autocomplete="off"
630
+ />
631
+ </LayoutCell>
632
+ <LayoutCell span={6}>
633
+ <Textfield
634
+ bind:value={$entity.cancelEmailAddress}
635
+ label="Cancel Email Address"
636
+ type="email"
637
+ style="width: 100%;"
638
+ input$autocomplete="off"
639
+ />
640
+ </LayoutCell>
641
+ {/if}
642
+ <LayoutCell span={12}>
643
+ <h5>Auth Token Revocation</h5>
644
+ <p>
645
+ The token revocation date is the date that all authentication tokens
646
+ must be issued after in order to work. Any token issued before this
647
+ date will be denied access. You can set this to now to log the user
648
+ out of all of their current sessions. The user will have to log in
649
+ again with their password.
650
+ </p>
651
+ </LayoutCell>
652
+ <LayoutCell span={12}>
653
+ <div style="display: flex; gap: 1em; align-items: center;">
654
+ <div style="flex-grow: 1;">
655
+ <Textfield
656
+ bind:value={$entity.revokeTokenDate}
657
+ label="Token Revocation Date (Timestamp)"
658
+ type="number"
659
+ style="width: 100%;"
660
+ input$autocomplete="off"
661
+ >
662
+ {#snippet helper()}
663
+ <HelperText persistent>
664
+ {$entity.revokeTokenDate === 0 ||
665
+ $entity.revokeTokenDate == null
666
+ ? 'Unset'
667
+ : new Date($entity.revokeTokenDate).toLocaleString()}
668
+ </HelperText>
669
+ {/snippet}
670
+ </Textfield>
671
+ </div>
672
+ <Button onclick={() => ($entity.revokeTokenDate = Date.now())}>
673
+ <Label>Now</Label>
674
+ </Button>
675
+ </div>
676
+ </LayoutCell>
677
+ <LayoutCell span={12}>
678
+ <h5>Two Factor Authentication</h5>
679
+ <p>
680
+ 2FA is an extra security measure that requires the user to have both
681
+ their password and a code generator device (usually an app on their
682
+ phone) to successfully authenticate.
683
+ </p>
684
+ </LayoutCell>
685
+ <LayoutCell span={12}>
686
+ <div style="display: inline-flex; gap: 1em; align-items: baseline;">
687
+ <span>
688
+ Has 2FA secret: {hasTOTPSecret ? 'Yes' : 'No'}
689
+ </span>
690
+ {#if hasTOTPSecret}
691
+ <Button onclick={removeTOTPSecret} disabled={saving}>
692
+ <Label>Remove 2FA</Label>
693
+ </Button>
694
+ {/if}
695
+ </div>
696
+ </LayoutCell>
697
+ </LayoutGrid>
698
+ {/if}
699
+
700
+ {#if failureMessage}
701
+ <div class="tilmeld-failure">
702
+ {failureMessage}
703
+ </div>
704
+ {/if}
705
+
706
+ <div
707
+ style="margin-top: 36px; display: flex; justify-content: space-between;"
708
+ >
709
+ <div>
710
+ <Button variant="raised" onclick={saveEntity} disabled={saving}>
711
+ <Label>Save User</Label>
712
+ </Button>
713
+ {#if $entity.guid}
714
+ <Button onclick={deleteEntity} disabled={saving}>
715
+ <Label>Delete</Label>
716
+ </Button>
717
+ {/if}
718
+ {#if success}
719
+ <span>Successfully saved!</span>
720
+ {/if}
721
+ </div>
722
+ {#if tilmeldSwitchUser && $entity.guid && !$entity.$is($user)}
723
+ <div>
724
+ <Button
725
+ onclick={() => {
726
+ tilmeldSwitchUserDialogOpen = true;
727
+ }}
728
+ disabled={saving}
729
+ >
730
+ <Label>Login as User</Label>
731
+ </Button>
732
+ </div>
733
+ {/if}
734
+ </div>
735
+ </section>
736
+ {/if}
737
+
738
+ <Dialog
739
+ bind:open={tilmeldSwitchUserDialogOpen}
740
+ aria-labelledby="switch-user-title"
741
+ aria-describedby="switch-user-content"
742
+ onSMUIDialogClosed={switchUserDialogCloseHandler}
743
+ >
744
+ <!-- Title cannot contain leading whitespace due to mdc-typography-baseline-top() -->
745
+ <Title id="switch-user-title">Switch User</Title>
746
+ <Content id="switch-user-content">
747
+ <p>
748
+ Switching users will let you use the app as this user, even if the user
749
+ account is disabled. You will remain logged in as this user until you log
750
+ out, at which time, you will go back to being logged in as yourself.
751
+ </p>
752
+ <p>Once you switch, you will be forwarded to the main app.</p>
753
+ </Content>
754
+ <Actions>
755
+ <Button action="cancel">
756
+ <Label>Cancel</Label>
757
+ </Button>
758
+ <Button action="switch">
759
+ <Label>Switch</Label>
760
+ </Button>
761
+ </Actions>
762
+ </Dialog>
763
+
764
+ <script lang="ts">
765
+ import { onMount } from 'svelte';
766
+ import type { Writable } from 'svelte/store';
767
+ import { writable } from 'svelte/store';
768
+ import { getContext } from 'svelte';
769
+ import { link, pop, replace } from 'svelte-spa-router';
770
+ import type {
771
+ AdminGroupData,
772
+ AdminUserData,
773
+ ClientConfig,
774
+ CurrentUserData,
775
+ } from '@nymphjs/tilmeld-client';
776
+ import type {
777
+ Group as GroupClass,
778
+ User as UserClass,
779
+ } from '@nymphjs/tilmeld-client';
780
+ import queryParser from '@nymphjs/query-parser';
781
+ import {
782
+ mdiArrowLeft,
783
+ mdiArrowRight,
784
+ mdiMagnify,
785
+ mdiMinus,
786
+ mdiPlus,
787
+ } from '@mdi/js';
788
+ import CircularProgress from '@smui/circular-progress';
789
+ import Tab from '@smui/tab';
790
+ import TabBar from '@smui/tab-bar';
791
+ import LayoutGrid, { Cell as LayoutCell } from '@smui/layout-grid';
792
+ import FormField from '@smui/form-field';
793
+ import Checkbox from '@smui/checkbox';
794
+ import List, { Item, Text, Meta } from '@smui/list';
795
+ import Paper from '@smui/paper';
796
+ import DataTable, { Head, Body, Row, Cell } from '@smui/data-table';
797
+ import Textfield, { Input } from '@smui/textfield';
798
+ import HelperText from '@smui/textfield/helper-text';
799
+ import IconButton from '@smui/icon-button';
800
+ import Button from '@smui/button';
801
+ import Dialog, { Title, Content, Actions } from '@smui/dialog';
802
+ import { Icon, Label } from '@smui/common';
803
+
804
+ import { User, Group } from '../nymph.js';
805
+
806
+ let { params }: { params: { guid: string } } = $props();
807
+
808
+ let entity: Writable<UserClass & AdminUserData> = writable(
809
+ User.factorySync(),
810
+ );
811
+ let sysAdmin = $state(false);
812
+ let tilmeldSwitchUser = $state(false);
813
+ let tilmeldSwitchUserDialogOpen = $state(false);
814
+ let activeTab: 'General' | 'Groups' | 'Abilities' | 'Security' =
815
+ $state('General');
816
+ let primaryGroupSearch = $state('');
817
+ let secondaryGroupSearch = $state('');
818
+ let ability = $state('');
819
+ let avatar = $state('https://secure.gravatar.com/avatar/?d=mm&s=40');
820
+ let hasTOTPSecret: boolean | undefined = $state();
821
+ let failureMessage: string | undefined = $state();
822
+ let passwordVerify = $state('');
823
+ let passwordVerified: boolean | undefined = $state();
824
+ let usernameTimer: number | undefined = undefined;
825
+ let usernameVerified: boolean | undefined = $state();
826
+ let usernameVerifiedMessage: string | undefined = $state();
827
+ let emailTimer: number | undefined = undefined;
828
+ let emailVerified: boolean | undefined = $state();
829
+ let emailVerifiedMessage: string | undefined = $state();
830
+ let saving = $state(false);
831
+ let success: boolean | undefined = $state();
832
+ let loading = $state(true);
833
+
834
+ const clientConfig =
835
+ getContext<Writable<ClientConfig | undefined>>('clientConfigStore');
836
+ const user =
837
+ getContext<Writable<(UserClass & CurrentUserData) | null | undefined>>(
838
+ 'userStore',
839
+ );
840
+
841
+ $effect(() => {
842
+ if (params) {
843
+ handleGuidParam();
844
+ }
845
+ });
846
+
847
+ onMount(async () => {
848
+ sysAdmin = (await $user?.$gatekeeper('system/admin')) ?? false;
849
+ tilmeldSwitchUser = (await $user?.$gatekeeper('tilmeld/switch')) ?? false;
850
+ });
851
+
852
+ async function handleGuidParam() {
853
+ loading = true;
854
+ failureMessage = undefined;
855
+ try {
856
+ $entity =
857
+ params.guid === '+' || params.guid === ' ' || params.guid === '%20'
858
+ ? await User.factory()
859
+ : await User.factory(params.guid);
860
+ oldUsername = $entity.username;
861
+ oldEmail = $entity.email;
862
+ await readyEntity();
863
+ } catch (e: any) {
864
+ failureMessage = e.message;
865
+ }
866
+ loading = false;
867
+ }
868
+
869
+ async function readyEntity() {
870
+ // Make sure all fields are defined.
871
+ if ($entity.enabled == null) {
872
+ $entity.enabled = false;
873
+ }
874
+ if ($entity.username == null) {
875
+ $entity.username = '';
876
+ }
877
+ if ($entity.email == null) {
878
+ $entity.email = '';
879
+ }
880
+ if ($entity.nameFirst == null) {
881
+ $entity.nameFirst = '';
882
+ }
883
+ if ($entity.nameMiddle == null) {
884
+ $entity.nameMiddle = '';
885
+ }
886
+ if ($entity.nameLast == null) {
887
+ $entity.nameLast = '';
888
+ }
889
+ if ($entity.avatar == null) {
890
+ $entity.avatar = '';
891
+ }
892
+ if ($entity.phone == null) {
893
+ $entity.phone = '';
894
+ }
895
+ if ($entity.passwordTemp == null) {
896
+ $entity.passwordTemp = '';
897
+ }
898
+ if ($entity.inheritAbilities == null) {
899
+ $entity.inheritAbilities = false;
900
+ }
901
+ if ($entity.secret == null) {
902
+ $entity.secret = '';
903
+ }
904
+ if ($entity.emailChangeDate == null) {
905
+ $entity.emailChangeDate = 0;
906
+ }
907
+ if ($entity.newEmailSecret == null) {
908
+ $entity.newEmailSecret = '';
909
+ }
910
+ if ($entity.newEmailAddress == null) {
911
+ $entity.newEmailAddress = '';
912
+ }
913
+ if ($entity.cancelEmailSecret == null) {
914
+ $entity.cancelEmailSecret = '';
915
+ }
916
+ if ($entity.cancelEmailAddress == null) {
917
+ $entity.cancelEmailAddress = '';
918
+ }
919
+ if ($entity.recoverSecret == null) {
920
+ $entity.recoverSecret = '';
921
+ }
922
+ if ($entity.recoverSecretDate == null) {
923
+ $entity.recoverSecretDate = 0;
924
+ }
925
+ if ($entity.revokeTokenDate == null) {
926
+ $entity.revokeTokenDate = 0;
927
+ }
928
+ [avatar, hasTOTPSecret] = await Promise.all([
929
+ $entity.$getAvatar(),
930
+ (!!$entity.guid && $entity?.$hasTOTPSecret()) || Promise.resolve(false),
931
+ $entity.$wakeAll(1),
932
+ ]);
933
+ $entity = $entity;
934
+ }
935
+
936
+ let primaryGroupsSearching = $state(false);
937
+ let primaryGroups: (GroupClass & AdminGroupData)[] | undefined = $state();
938
+ async function searchPrimaryGroups() {
939
+ primaryGroupsSearching = true;
940
+ failureMessage = undefined;
941
+ if (primaryGroupSearch.trim() == '') {
942
+ return;
943
+ }
944
+ try {
945
+ const [options, ...selectors] = queryParser({
946
+ query: primaryGroupSearch,
947
+ entityClass: Group,
948
+ defaultFields: ['groupname', 'name', 'email'],
949
+ qrefMap: {
950
+ User: {
951
+ class: User,
952
+ defaultFields: ['username', 'name', 'email'],
953
+ },
954
+ Group: {
955
+ class: Group,
956
+ defaultFields: ['groupname', 'name', 'email'],
957
+ },
958
+ },
959
+ });
960
+ primaryGroups = (await Group.getPrimaryGroups(options, selectors)).filter(
961
+ (group) => {
962
+ return !group.$is($entity.group);
963
+ },
964
+ );
965
+ } catch (e: any) {
966
+ failureMessage = e?.message;
967
+ }
968
+ primaryGroupsSearching = false;
969
+ }
970
+ function primaryGroupSearchKeyDown(event: CustomEvent | KeyboardEvent) {
971
+ event = event as KeyboardEvent;
972
+ if (event.key === 'Enter') searchPrimaryGroups();
973
+ }
974
+
975
+ let secondaryGroupsSearching = $state(false);
976
+ let secondaryGroups: (GroupClass & AdminGroupData)[] | undefined = $state();
977
+ async function searchSecondaryGroups() {
978
+ secondaryGroupsSearching = true;
979
+ failureMessage = undefined;
980
+ if (secondaryGroupSearch.trim() == '') {
981
+ return;
982
+ }
983
+ try {
984
+ const [options, ...selectors] = queryParser({
985
+ query: secondaryGroupSearch,
986
+ entityClass: Group,
987
+ defaultFields: ['groupname', 'name', 'email'],
988
+ qrefMap: {
989
+ User: {
990
+ class: User,
991
+ defaultFields: ['username', 'name', 'email'],
992
+ },
993
+ Group: {
994
+ class: Group,
995
+ defaultFields: ['groupname', 'name', 'email'],
996
+ },
997
+ },
998
+ });
999
+ secondaryGroups = (
1000
+ await Group.getSecondaryGroups(options, selectors)
1001
+ ).filter((group) => {
1002
+ return !group.$inArray($entity.groups ?? []);
1003
+ });
1004
+ } catch (e: any) {
1005
+ failureMessage = e?.message;
1006
+ }
1007
+ secondaryGroupsSearching = false;
1008
+ }
1009
+ function secondaryGroupSearchKeyDown(event: CustomEvent | KeyboardEvent) {
1010
+ event = event as KeyboardEvent;
1011
+ if (event.key === 'Enter') searchSecondaryGroups();
1012
+ }
1013
+
1014
+ let oldUsername: string | undefined = undefined;
1015
+ $effect(() => {
1016
+ if ($entity && $entity.username !== oldUsername) {
1017
+ if (usernameTimer) {
1018
+ clearTimeout(usernameTimer);
1019
+ }
1020
+ usernameTimer = setTimeout(async () => {
1021
+ if ($entity.username === '') {
1022
+ usernameVerified = undefined;
1023
+ usernameVerifiedMessage = undefined;
1024
+ return;
1025
+ }
1026
+ try {
1027
+ const data = await $entity.$checkUsername();
1028
+ usernameVerified = data.result;
1029
+ usernameVerifiedMessage = data.message;
1030
+ } catch (e: any) {
1031
+ usernameVerified = false;
1032
+ usernameVerifiedMessage = e?.message;
1033
+ }
1034
+ }, 400);
1035
+ oldUsername = $entity.username;
1036
+ }
1037
+ });
1038
+
1039
+ let oldEmail: string | undefined = undefined;
1040
+ $effect(() => {
1041
+ if ($entity && $entity.email !== oldEmail) {
1042
+ if (emailTimer) {
1043
+ clearTimeout(emailTimer);
1044
+ }
1045
+ emailTimer = setTimeout(async () => {
1046
+ if ($entity.email === '') {
1047
+ emailVerified = undefined;
1048
+ emailVerifiedMessage = undefined;
1049
+ return;
1050
+ }
1051
+ try {
1052
+ const data = await $entity.$checkEmail();
1053
+ emailVerified = data.result;
1054
+ emailVerifiedMessage = data.message;
1055
+ } catch (e: any) {
1056
+ emailVerified = false;
1057
+ emailVerifiedMessage = e?.message;
1058
+ }
1059
+ }, 400);
1060
+ oldEmail = $entity.email;
1061
+ }
1062
+ });
1063
+
1064
+ function doVerifyPassword() {
1065
+ if (
1066
+ ($entity.passwordTemp == null || $entity.passwordTemp === '') &&
1067
+ passwordVerify === ''
1068
+ ) {
1069
+ passwordVerified = undefined;
1070
+ } else {
1071
+ passwordVerified = $entity.passwordTemp === passwordVerify;
1072
+ }
1073
+ }
1074
+
1075
+ function addAbility() {
1076
+ if (ability === '') {
1077
+ return;
1078
+ }
1079
+ $entity.abilities?.push(ability);
1080
+ $entity = $entity;
1081
+ ability = '';
1082
+ }
1083
+ function abilityKeyDown(event: CustomEvent | KeyboardEvent) {
1084
+ event = event as KeyboardEvent;
1085
+ if (event.key === 'Enter') addAbility();
1086
+ }
1087
+
1088
+ function addSystemAdminAbility() {
1089
+ if ($entity.abilities?.indexOf('system/admin') === -1) {
1090
+ $entity.abilities?.push('system/admin');
1091
+ $entity = $entity;
1092
+ }
1093
+ }
1094
+
1095
+ function addTilmeldAdminAbility() {
1096
+ if ($entity.abilities?.indexOf('tilmeld/admin') === -1) {
1097
+ $entity.abilities?.push('tilmeld/admin');
1098
+ $entity = $entity;
1099
+ }
1100
+ }
1101
+
1102
+ function addTilmeldSwitchAbility() {
1103
+ if ($entity.abilities?.indexOf('tilmeld/switch') === -1) {
1104
+ $entity.abilities?.push('tilmeld/switch');
1105
+ $entity = $entity;
1106
+ }
1107
+ }
1108
+
1109
+ async function removeTOTPSecret() {
1110
+ failureMessage = undefined;
1111
+ if (confirm("Are you sure you want to remove the user's 2FA?")) {
1112
+ saving = true;
1113
+ try {
1114
+ const result = await $entity.$removeTOTPSecret();
1115
+
1116
+ if (result.result) {
1117
+ hasTOTPSecret = false;
1118
+ } else {
1119
+ failureMessage = result.message;
1120
+ }
1121
+ } catch (e: any) {
1122
+ failureMessage = e?.message;
1123
+ }
1124
+ saving = false;
1125
+ }
1126
+ }
1127
+
1128
+ async function saveEntity() {
1129
+ if (
1130
+ ($entity.passwordTemp != null || $entity.passwordTemp !== '') &&
1131
+ $entity.passwordTemp !== passwordVerify
1132
+ ) {
1133
+ failureMessage = "Passwords don't match!";
1134
+ return;
1135
+ }
1136
+
1137
+ saving = true;
1138
+ failureMessage = undefined;
1139
+ const newEntity = $entity.guid == null;
1140
+ try {
1141
+ await $entity.$save();
1142
+ await readyEntity();
1143
+ success = true;
1144
+ passwordVerify = '';
1145
+ if (newEntity) {
1146
+ replace(`/users/edit/${encodeURIComponent($entity.guid || '')}`);
1147
+ }
1148
+ setTimeout(() => {
1149
+ success = undefined;
1150
+ }, 1000);
1151
+ } catch (e: any) {
1152
+ console.log('error:', e);
1153
+ failureMessage = e?.message;
1154
+ }
1155
+ saving = false;
1156
+ }
1157
+
1158
+ async function deleteEntity() {
1159
+ failureMessage = undefined;
1160
+ if (confirm('Are you sure you want to delete this?')) {
1161
+ saving = true;
1162
+ try {
1163
+ await $entity.$delete();
1164
+ pop();
1165
+ } catch (e: any) {
1166
+ failureMessage = e?.message;
1167
+ }
1168
+ saving = false;
1169
+ }
1170
+ }
1171
+
1172
+ async function switchUserDialogCloseHandler(
1173
+ e: CustomEvent<{ action: string }>,
1174
+ ) {
1175
+ if (e.detail.action === 'switch') {
1176
+ saving = true;
1177
+ try {
1178
+ const result = await $entity.$switchUser();
1179
+
1180
+ if (result.result) {
1181
+ window.location.href =
1182
+ (window as unknown as { appUrl: string }).appUrl ||
1183
+ window.location.href;
1184
+ } else {
1185
+ failureMessage = result.message;
1186
+ }
1187
+ } catch (e: any) {
1188
+ failureMessage = e?.message;
1189
+ }
1190
+ saving = false;
1191
+ }
1192
+ }
1193
+ </script>