@nymphjs/tilmeld-setup 1.0.0-beta.11 → 1.0.0-beta.111

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,1202 @@
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
10
+ title="Back"
11
+ onclick={() => router.navigate('', { historyAPIMethod: 'back' })}
12
+ >
13
+ <Icon tag="svg" viewBox="0 0 24 24">
14
+ <path fill="currentColor" d={mdiArrowLeft} />
15
+ </Icon>
16
+ </IconButton>
17
+ <h2 style="margin: 0px 12px 0px;" class="mdc-typography--headline5">
18
+ Editing {$entity.guid
19
+ ? $entity.$is($user)
20
+ ? 'Yourself'
21
+ : ($entity.name ?? $entity.username)
22
+ : 'New User'}
23
+ </h2>
24
+ </div>
25
+
26
+ <TabBar
27
+ tabs={['General', 'Groups', 'Abilities', 'Security']}
28
+ bind:active={activeTab}
29
+ >
30
+ {#snippet tab(tab)}
31
+ <Tab {tab}>
32
+ <Label>{tab}</Label>
33
+ </Tab>
34
+ {/snippet}
35
+ </TabBar>
36
+
37
+ <section>
38
+ {#if activeTab === 'General'}
39
+ <LayoutGrid style="padding: 0;">
40
+ <LayoutCell span={4}>
41
+ <div class="mdc-typography--headline6">GUID</div>
42
+ <code>{$entity.guid}</code>
43
+ </LayoutCell>
44
+ <LayoutCell span={4}>
45
+ <FormField>
46
+ <Checkbox bind:checked={$entity.enabled} />
47
+ {#snippet label()}
48
+ Enabled (Able to log in)
49
+ {/snippet}
50
+ </FormField>
51
+ </LayoutCell>
52
+ <LayoutCell span={4} style="text-align: end;">
53
+ <a href="https://en.gravatar.com/" target="_blank" rel="noreferrer">
54
+ <img src={avatar} alt="Avatar" title="Avatar by Gravatar" />
55
+ </a>
56
+ </LayoutCell>
57
+ {#if !$clientConfig.emailUsernames}
58
+ <LayoutCell
59
+ span={$clientConfig.userFields.includes('email') ? 6 : 12}
60
+ >
61
+ <Textfield
62
+ bind:value={$entity.username}
63
+ label="Username"
64
+ type="text"
65
+ style="width: 100%;"
66
+ helperLine$style="width: 100%;"
67
+ invalid={usernameVerified === false}
68
+ input$autocomplete="off"
69
+ input$autocapitalize="off"
70
+ input$spellcheck="false"
71
+ >
72
+ {#snippet helper()}
73
+ <HelperText persistent>
74
+ {usernameVerifiedMessage ?? ''}
75
+ </HelperText>
76
+ {/snippet}
77
+ </Textfield>
78
+ </LayoutCell>
79
+ {/if}
80
+ {#if $clientConfig.userFields.includes('email')}
81
+ <LayoutCell span={$clientConfig.emailUsernames ? 12 : 6}>
82
+ <Textfield
83
+ bind:value={$entity.email}
84
+ label="Email"
85
+ type="email"
86
+ style="width: 100%;"
87
+ helperLine$style="width: 100%;"
88
+ invalid={emailVerified === false}
89
+ input$autocomplete="off"
90
+ input$autocapitalize="off"
91
+ input$spellcheck="false"
92
+ >
93
+ {#snippet helper()}
94
+ <HelperText persistent>
95
+ {emailVerifiedMessage ?? ''}
96
+ </HelperText>
97
+ {/snippet}
98
+ </Textfield>
99
+ </LayoutCell>
100
+ {/if}
101
+ {#if $clientConfig.userFields.includes('name')}
102
+ <LayoutCell span={4}>
103
+ <Textfield
104
+ bind:value={$entity.nameFirst}
105
+ label="First Name"
106
+ type="text"
107
+ style="width: 100%;"
108
+ input$autocomplete="off"
109
+ />
110
+ </LayoutCell>
111
+ <LayoutCell span={4}>
112
+ <Textfield
113
+ bind:value={$entity.nameMiddle}
114
+ label="Middle Name"
115
+ type="text"
116
+ style="width: 100%;"
117
+ input$autocomplete="off"
118
+ />
119
+ </LayoutCell>
120
+ <LayoutCell span={4}>
121
+ <Textfield
122
+ bind:value={$entity.nameLast}
123
+ label="Last Name"
124
+ type="text"
125
+ style="width: 100%;"
126
+ input$autocomplete="off"
127
+ />
128
+ </LayoutCell>
129
+ {/if}
130
+ <LayoutCell span={$clientConfig.userFields.includes('phone') ? 8 : 12}>
131
+ <Textfield
132
+ bind:value={$entity.avatar}
133
+ label="Avatar"
134
+ type="text"
135
+ style="width: 100%;"
136
+ input$autocomplete="off"
137
+ />
138
+ </LayoutCell>
139
+ {#if $clientConfig.userFields.includes('phone')}
140
+ <LayoutCell span={4}>
141
+ <Textfield
142
+ bind:value={$entity.phone}
143
+ label="Phone"
144
+ type="tel"
145
+ style="width: 100%;"
146
+ input$autocomplete="off"
147
+ />
148
+ </LayoutCell>
149
+ {/if}
150
+ <LayoutCell span={6}>
151
+ <Textfield
152
+ bind:value={$entity.passwordTemp}
153
+ label={`${$entity.guid ? 'Update ' : ''}Password`}
154
+ type="password"
155
+ style="width: 100%;"
156
+ input$autocomplete="off"
157
+ />
158
+ </LayoutCell>
159
+ <LayoutCell span={6}>
160
+ <Textfield
161
+ bind:value={passwordVerify}
162
+ label="Repeat Password"
163
+ type="password"
164
+ style="width: 100%;"
165
+ invalid={passwordVerified === false}
166
+ input$autocomplete="off"
167
+ onblur={doVerifyPassword}
168
+ />
169
+ </LayoutCell>
170
+ </LayoutGrid>
171
+ {/if}
172
+
173
+ {#if activeTab === 'Groups'}
174
+ {#if $entity.guid == null}
175
+ <p style="margin-top: 0;">
176
+ When you leave primary group empty, if Nymph is configured to generate
177
+ primary groups, one will be generated for this new user. Otherwise,
178
+ Nymph will assign the default primary group. Likewise, when you leave
179
+ secondary groups empty, Nymph will assign the default secondary
180
+ groups.
181
+ </p>
182
+ {/if}
183
+
184
+ <h5 style={$entity.guid == null ? '' : 'margin-top: 0;'}>
185
+ Primary Group
186
+ </h5>
187
+
188
+ <Paper
189
+ style="display: flex; justify-content: space-between; align-items: center;"
190
+ >
191
+ {#if !$entity.group}
192
+ No primary group
193
+ {:else}
194
+ <a href="#/groups/edit/{encodeURIComponent($entity.group.guid || '')}"
195
+ >{$clientConfig.userFields.includes('name')
196
+ ? $entity.group.name + ' (' + $entity.group.groupname + ')'
197
+ : $entity.group.groupname}</a
198
+ >
199
+
200
+ <IconButton
201
+ onclick={() => {
202
+ delete $entity.group;
203
+ $entity = $entity;
204
+ }}
205
+ >
206
+ <Icon tag="svg" viewBox="0 0 24 24">
207
+ <path fill="currentColor" d={mdiMinus} />
208
+ </Icon>
209
+ </IconButton>
210
+ {/if}
211
+ </Paper>
212
+
213
+ <h6>Change Primary Group</h6>
214
+
215
+ <div class="solo-search-container solo-container">
216
+ <Paper class="solo-paper" elevation={1}>
217
+ <Icon class="solo-icon" tag="svg" viewBox="0 0 24 24">
218
+ <path fill="currentColor" d={mdiMagnify} />
219
+ </Icon>
220
+ <Input
221
+ bind:value={primaryGroupSearch}
222
+ onkeydown={primaryGroupSearchKeyDown}
223
+ placeholder="Primary Group Search"
224
+ class="solo-input"
225
+ />
226
+ </Paper>
227
+ <IconButton
228
+ onclick={searchPrimaryGroups}
229
+ disabled={primaryGroupSearch === ''}
230
+ class="solo-fab"
231
+ title="Search"
232
+ >
233
+ <Icon tag="svg" viewBox="0 0 24 24">
234
+ <path fill="currentColor" d={mdiArrowRight} />
235
+ </Icon>
236
+ </IconButton>
237
+ </div>
238
+
239
+ {#if primaryGroupsSearching}
240
+ <div
241
+ style="display: flex; justify-content: center; align-items: center;"
242
+ >
243
+ <CircularProgress style="height: 32px; width: 32px;" indeterminate />
244
+ </div>
245
+ {:else if primaryGroups != null}
246
+ <DataTable table$aria-label="Primary group list" style="width: 100%;">
247
+ <Head>
248
+ <Row>
249
+ {#if !$clientConfig.emailUsernames}
250
+ <Cell>Groupname</Cell>
251
+ {/if}
252
+ {#if $clientConfig.userFields.includes('name')}
253
+ <Cell>Name</Cell>
254
+ {/if}
255
+ {#if $clientConfig.userFields.includes('email')}
256
+ <Cell>Email</Cell>
257
+ {/if}
258
+ <Cell>Enabled</Cell>
259
+ </Row>
260
+ </Head>
261
+ <Body>
262
+ <!-- Purposefully not making these links. -->
263
+ {#each primaryGroups as curEntity (curEntity.guid)}
264
+ <Row
265
+ onclick={() => ($entity.group = curEntity)}
266
+ style="cursor: pointer;"
267
+ >
268
+ {#if !$clientConfig.emailUsernames}
269
+ <Cell>{curEntity.groupname}</Cell>
270
+ {/if}
271
+ {#if $clientConfig.userFields.includes('name')}
272
+ <Cell>{curEntity.name}</Cell>
273
+ {/if}
274
+ {#if $clientConfig.userFields.includes('email')}
275
+ <Cell>{curEntity.email}</Cell>
276
+ {/if}
277
+ <Cell>{curEntity.enabled ? 'Yes' : 'No'}</Cell>
278
+ </Row>
279
+ {/each}
280
+ </Body>
281
+ </DataTable>
282
+ {/if}
283
+
284
+ <h5>Secondary Groups</h5>
285
+
286
+ <DataTable
287
+ table$aria-label="Current secondary groups"
288
+ style="width: 100%;"
289
+ >
290
+ <Head>
291
+ <Row>
292
+ {#if !$clientConfig.emailUsernames}
293
+ <Cell>Groupname</Cell>
294
+ {/if}
295
+ {#if $clientConfig.userFields.includes('name')}
296
+ <Cell>Name</Cell>
297
+ {/if}
298
+ {#if $clientConfig.userFields.includes('email')}
299
+ <Cell>Email</Cell>
300
+ {/if}
301
+ <Cell>Enabled</Cell>
302
+ <Cell>Remove</Cell>
303
+ </Row>
304
+ </Head>
305
+ <Body>
306
+ {#each $entity.groups || [] as curEntity, index (curEntity.guid)}
307
+ <Row>
308
+ {#if !$clientConfig.emailUsernames}
309
+ <Cell
310
+ ><a
311
+ href="#/groups/edit/{encodeURIComponent(
312
+ curEntity.guid || '',
313
+ )}">{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
+ )}">{curEntity.name}</a
323
+ ></Cell
324
+ >
325
+ {/if}
326
+ {#if $clientConfig.userFields.includes('email')}
327
+ <Cell
328
+ ><a
329
+ href="#/groups/edit/{encodeURIComponent(
330
+ curEntity.guid || '',
331
+ )}">{curEntity.email}</a
332
+ ></Cell
333
+ >
334
+ {/if}
335
+ <Cell>{curEntity.enabled ? 'Yes' : 'No'}</Cell>
336
+ <Cell>
337
+ <IconButton
338
+ onclick={() => {
339
+ $entity.groups?.splice(index, 1);
340
+ $entity = $entity;
341
+ }}
342
+ >
343
+ <Icon tag="svg" viewBox="0 0 24 24">
344
+ <path fill="currentColor" d={mdiMinus} />
345
+ </Icon>
346
+ </IconButton>
347
+ </Cell>
348
+ </Row>
349
+ {:else}
350
+ <Row>
351
+ <Cell
352
+ colspan={2 +
353
+ (!$clientConfig.emailUsernames ? 1 : 0) +
354
+ ($clientConfig.userFields.includes('name') ? 1 : 0) +
355
+ ($clientConfig.userFields.includes('email') ? 1 : 0)}
356
+ >
357
+ No secondary groups
358
+ </Cell>
359
+ </Row>
360
+ {/each}
361
+ </Body>
362
+ </DataTable>
363
+
364
+ <h6>Add Secondary Groups</h6>
365
+
366
+ <div class="solo-search-container solo-container">
367
+ <Paper class="solo-paper" elevation={1}>
368
+ <Icon class="solo-icon" tag="svg" viewBox="0 0 24 24">
369
+ <path fill="currentColor" d={mdiMagnify} />
370
+ </Icon>
371
+ <Input
372
+ bind:value={secondaryGroupSearch}
373
+ onkeydown={secondaryGroupSearchKeyDown}
374
+ placeholder="Secondary Group Search"
375
+ class="solo-input"
376
+ />
377
+ </Paper>
378
+ <IconButton
379
+ onclick={searchSecondaryGroups}
380
+ disabled={secondaryGroupSearch === ''}
381
+ class="solo-fab"
382
+ title="Search"
383
+ >
384
+ <Icon tag="svg" viewBox="0 0 24 24">
385
+ <path fill="currentColor" d={mdiArrowRight} />
386
+ </Icon>
387
+ </IconButton>
388
+ </div>
389
+
390
+ {#if secondaryGroupsSearching}
391
+ <div
392
+ style="display: flex; justify-content: center; align-items: center;"
393
+ >
394
+ <CircularProgress style="height: 32px; width: 32px;" indeterminate />
395
+ </div>
396
+ {:else if secondaryGroups != null}
397
+ <DataTable table$aria-label="Secondary group list" style="width: 100%;">
398
+ <Head>
399
+ <Row>
400
+ {#if !$clientConfig.emailUsernames}
401
+ <Cell>Groupname</Cell>
402
+ {/if}
403
+ {#if $clientConfig.userFields.includes('name')}
404
+ <Cell>Name</Cell>
405
+ {/if}
406
+ {#if $clientConfig.userFields.includes('email')}
407
+ <Cell>Email</Cell>
408
+ {/if}
409
+ <Cell>Enabled</Cell>
410
+ </Row>
411
+ </Head>
412
+ <Body>
413
+ <!-- Purposefully not making these links. -->
414
+ {#each secondaryGroups as curEntity, index (curEntity.guid)}
415
+ <Row
416
+ onclick={() => {
417
+ $entity.groups?.push(curEntity);
418
+ secondaryGroups?.splice(index, 1);
419
+ $entity = $entity;
420
+ }}
421
+ style="cursor: pointer;"
422
+ >
423
+ {#if !$clientConfig.emailUsernames}
424
+ <Cell>{curEntity.groupname}</Cell>
425
+ {/if}
426
+ {#if $clientConfig.userFields.includes('name')}
427
+ <Cell>{curEntity.name}</Cell>
428
+ {/if}
429
+ {#if $clientConfig.userFields.includes('email')}
430
+ <Cell>{curEntity.email}</Cell>
431
+ {/if}
432
+ <Cell>{curEntity.enabled ? 'Yes' : 'No'}</Cell>
433
+ </Row>
434
+ {/each}
435
+ </Body>
436
+ </DataTable>
437
+ {/if}
438
+ {/if}
439
+
440
+ {#if activeTab === 'Abilities'}
441
+ <h5 style="margin-top: 0;">Abilities</h5>
442
+
443
+ <List nonInteractive>
444
+ {#each $entity.abilities || [] as ability, index (ability)}
445
+ <Item>
446
+ <Text>
447
+ {ability}
448
+ </Text>
449
+ <Meta>
450
+ <IconButton
451
+ onclick={() => {
452
+ $entity.abilities?.splice(index, 1);
453
+ $entity = $entity;
454
+ }}
455
+ >
456
+ <Icon tag="svg" viewBox="0 0 24 24">
457
+ <path fill="currentColor" d={mdiMinus} />
458
+ </Icon>
459
+ </IconButton>
460
+ </Meta>
461
+ </Item>
462
+ {:else}
463
+ <Item>
464
+ <Text>No abilities</Text>
465
+ </Item>
466
+ {/each}
467
+ </List>
468
+
469
+ <h6>Add Ability</h6>
470
+
471
+ <div style="display: flex; align-items: center; flex-wrap: wrap;">
472
+ <Textfield
473
+ bind:value={ability}
474
+ label="Ability"
475
+ type="text"
476
+ style="width: 250px; max-width: 100%;"
477
+ onkeydown={abilityKeyDown}
478
+ />
479
+ <IconButton onclick={addAbility}>
480
+ <Icon tag="svg" viewBox="0 0 24 24">
481
+ <path fill="currentColor" d={mdiPlus} />
482
+ </Icon>
483
+ </IconButton>
484
+ {#if sysAdmin}
485
+ <Button
486
+ onclick={addSystemAdminAbility}
487
+ title="System Admins have all abilities. Gatekeeper checks always return true."
488
+ >
489
+ <Label>System Admin</Label>
490
+ </Button>
491
+ <Button
492
+ onclick={addTilmeldAdminAbility}
493
+ title="Tilmeld Admins have the ability to modify, create, and delete users and groups, and grant and revoke abilities."
494
+ >
495
+ <Label>Tilmeld Admin</Label>
496
+ </Button>
497
+ <Button
498
+ onclick={addTilmeldSwitchAbility}
499
+ title="The switch user ability lets a user log in as another non-admin user without needing their password."
500
+ >
501
+ <Label>Switch User</Label>
502
+ </Button>
503
+ {/if}
504
+ </div>
505
+
506
+ <h6>Inherit Abilities</h6>
507
+
508
+ <div>
509
+ <FormField>
510
+ <Checkbox bind:checked={$entity.inheritAbilities} />
511
+ {#snippet label()}
512
+ Additionally, inherit the abilities of the group(s) this user
513
+ belongs to.
514
+ {/snippet}
515
+ </FormField>
516
+ </div>
517
+ {/if}
518
+
519
+ {#if activeTab === 'Security'}
520
+ <LayoutGrid style="padding: 0;">
521
+ {#if $clientConfig.userFields.includes('email')}
522
+ <LayoutCell span={12}>
523
+ <h5>Verification</h5>
524
+ <p>
525
+ The email verification secret is the code emailed to the user to
526
+ verify their address when they first sign up.
527
+ </p>
528
+ </LayoutCell>
529
+ <LayoutCell span={12}>
530
+ <Textfield
531
+ bind:value={$entity.secret}
532
+ label="Email Verification Secret"
533
+ type="text"
534
+ style="width: 100%;"
535
+ input$autocomplete="off"
536
+ />
537
+ </LayoutCell>
538
+ <LayoutCell span={12}>
539
+ <h5>Account Recovery</h5>
540
+ <p>
541
+ The account recovery secret is the code emailed to the user to
542
+ allow them to change their password and recover their account. The
543
+ date is used to determine if the code has expired.
544
+ </p>
545
+ </LayoutCell>
546
+ <LayoutCell span={6}>
547
+ <Textfield
548
+ bind:value={$entity.recoverSecret}
549
+ label="Account Recovery Secret"
550
+ type="text"
551
+ style="width: 100%;"
552
+ input$autocomplete="off"
553
+ />
554
+ </LayoutCell>
555
+ <LayoutCell span={6}>
556
+ <Textfield
557
+ bind:value={$entity.recoverSecretDate}
558
+ label="Account Recovery Date (Timestamp)"
559
+ type="number"
560
+ style="width: 100%;"
561
+ input$autocomplete="off"
562
+ >
563
+ {#snippet helper()}
564
+ <HelperText persistent>
565
+ {$entity.recoverSecretDate === 0 ||
566
+ $entity.recoverSecretDate == null
567
+ ? 'Unset'
568
+ : new Date($entity.recoverSecretDate).toLocaleString()}
569
+ </HelperText>
570
+ {/snippet}
571
+ </Textfield>
572
+ </LayoutCell>
573
+ <LayoutCell span={12}>
574
+ <h5>Email Change</h5>
575
+ <p>
576
+ An email change uses all of the following properties. The email
577
+ change date is used to rate limit email changes and to allow the
578
+ user to cancel the change within the rate limit time. The new
579
+ secret is emailed to the new address, and when the user clicks the
580
+ link, that email address is set for their account. The cancel
581
+ secret is emailed to the old address and will reset the user's
582
+ email to the cancel address if the link is clicked in time.
583
+ </p>
584
+ </LayoutCell>
585
+ <LayoutCell span={12}>
586
+ <Textfield
587
+ bind:value={$entity.emailChangeDate}
588
+ label="Email Change Date (Timestamp)"
589
+ type="number"
590
+ style="width: 100%;"
591
+ input$autocomplete="off"
592
+ >
593
+ {#snippet helper()}
594
+ <HelperText persistent>
595
+ {$entity.emailChangeDate === 0 ||
596
+ $entity.emailChangeDate == null
597
+ ? 'Unset'
598
+ : new Date($entity.emailChangeDate).toLocaleString()}
599
+ </HelperText>
600
+ {/snippet}
601
+ </Textfield>
602
+ </LayoutCell>
603
+ <LayoutCell span={6}>
604
+ <Textfield
605
+ bind:value={$entity.newEmailSecret}
606
+ label="New Email Verification Secret"
607
+ type="text"
608
+ style="width: 100%;"
609
+ input$autocomplete="off"
610
+ />
611
+ </LayoutCell>
612
+ <LayoutCell span={6}>
613
+ <Textfield
614
+ bind:value={$entity.newEmailAddress}
615
+ label="New Email Address"
616
+ type="email"
617
+ style="width: 100%;"
618
+ input$autocomplete="off"
619
+ />
620
+ </LayoutCell>
621
+ <LayoutCell span={6}>
622
+ <Textfield
623
+ bind:value={$entity.cancelEmailSecret}
624
+ label="Cancel Email Verification Secret"
625
+ type="text"
626
+ style="width: 100%;"
627
+ input$autocomplete="off"
628
+ />
629
+ </LayoutCell>
630
+ <LayoutCell span={6}>
631
+ <Textfield
632
+ bind:value={$entity.cancelEmailAddress}
633
+ label="Cancel Email Address"
634
+ type="email"
635
+ style="width: 100%;"
636
+ input$autocomplete="off"
637
+ />
638
+ </LayoutCell>
639
+ {/if}
640
+ <LayoutCell span={12}>
641
+ <h5>Auth Token Revocation</h5>
642
+ <p>
643
+ The token revocation date is the date that all authentication tokens
644
+ must be issued after in order to work. Any token issued before this
645
+ date will be denied access. You can set this to now to log the user
646
+ out of all of their current sessions. The user will have to log in
647
+ again with their password.
648
+ </p>
649
+ </LayoutCell>
650
+ <LayoutCell span={12}>
651
+ <div style="display: flex; gap: 1em; align-items: center;">
652
+ <div style="flex-grow: 1;">
653
+ <Textfield
654
+ bind:value={$entity.revokeTokenDate}
655
+ label="Token Revocation Date (Timestamp)"
656
+ type="number"
657
+ style="width: 100%;"
658
+ input$autocomplete="off"
659
+ >
660
+ {#snippet helper()}
661
+ <HelperText persistent>
662
+ {$entity.revokeTokenDate === 0 ||
663
+ $entity.revokeTokenDate == null
664
+ ? 'Unset'
665
+ : new Date($entity.revokeTokenDate).toLocaleString()}
666
+ </HelperText>
667
+ {/snippet}
668
+ </Textfield>
669
+ </div>
670
+ <Button onclick={() => ($entity.revokeTokenDate = Date.now())}>
671
+ <Label>Now</Label>
672
+ </Button>
673
+ </div>
674
+ </LayoutCell>
675
+ <LayoutCell span={12}>
676
+ <h5>Two Factor Authentication</h5>
677
+ <p>
678
+ 2FA is an extra security measure that requires the user to have both
679
+ their password and a code generator device (usually an app on their
680
+ phone) to successfully authenticate.
681
+ </p>
682
+ </LayoutCell>
683
+ <LayoutCell span={12}>
684
+ <div style="display: inline-flex; gap: 1em; align-items: baseline;">
685
+ <span>
686
+ Has 2FA secret: {hasTOTPSecret ? 'Yes' : 'No'}
687
+ </span>
688
+ {#if hasTOTPSecret}
689
+ <Button onclick={removeTOTPSecret} disabled={saving}>
690
+ <Label>Remove 2FA</Label>
691
+ </Button>
692
+ {/if}
693
+ </div>
694
+ </LayoutCell>
695
+ </LayoutGrid>
696
+ {/if}
697
+
698
+ {#if failureMessage}
699
+ <div class="tilmeld-failure">
700
+ {failureMessage}
701
+ </div>
702
+ {/if}
703
+
704
+ <div
705
+ style="margin-top: 36px; display: flex; justify-content: space-between;"
706
+ >
707
+ <div>
708
+ <Button variant="raised" onclick={saveEntity} disabled={saving}>
709
+ <Label>Save User</Label>
710
+ </Button>
711
+ {#if $entity.guid}
712
+ <Button onclick={deleteEntity} disabled={saving}>
713
+ <Label>Delete</Label>
714
+ </Button>
715
+ {/if}
716
+ {#if success}
717
+ <span>Successfully saved!</span>
718
+ {/if}
719
+ </div>
720
+ {#if tilmeldSwitchUser && $entity.guid && !$entity.$is($user)}
721
+ <div>
722
+ <Button
723
+ onclick={() => {
724
+ tilmeldSwitchUserDialogOpen = true;
725
+ }}
726
+ disabled={saving}
727
+ >
728
+ <Label>Login as User</Label>
729
+ </Button>
730
+ </div>
731
+ {/if}
732
+ </div>
733
+ </section>
734
+ {/if}
735
+
736
+ <Dialog
737
+ bind:open={tilmeldSwitchUserDialogOpen}
738
+ aria-labelledby="switch-user-title"
739
+ aria-describedby="switch-user-content"
740
+ onSMUIDialogClosed={switchUserDialogCloseHandler}
741
+ >
742
+ <!-- Title cannot contain leading whitespace due to mdc-typography-baseline-top() -->
743
+ <Title id="switch-user-title">Switch User</Title>
744
+ <Content id="switch-user-content">
745
+ <p>
746
+ Switching users will let you use the app as this user, even if the user
747
+ account is disabled. You will remain logged in as this user until you log
748
+ out, at which time, you will go back to being logged in as yourself.
749
+ </p>
750
+ <p>Once you switch, you will be forwarded to the main app.</p>
751
+ </Content>
752
+ <Actions>
753
+ <Button action="cancel">
754
+ <Label>Cancel</Label>
755
+ </Button>
756
+ <Button action="switch">
757
+ <Label>Switch</Label>
758
+ </Button>
759
+ </Actions>
760
+ </Dialog>
761
+
762
+ <script lang="ts">
763
+ import { onMount } from 'svelte';
764
+ import type { Writable } from 'svelte/store';
765
+ import { writable } from 'svelte/store';
766
+ import type Navigo from 'navigo';
767
+ import type {
768
+ AdminGroupData,
769
+ AdminUserData,
770
+ ClientConfig,
771
+ CurrentUserData,
772
+ } from '@nymphjs/tilmeld-client';
773
+ import type {
774
+ Group as GroupClass,
775
+ User as UserClass,
776
+ } from '@nymphjs/tilmeld-client';
777
+ import queryParser from '@nymphjs/query-parser';
778
+ import {
779
+ mdiArrowLeft,
780
+ mdiArrowRight,
781
+ mdiMagnify,
782
+ mdiMinus,
783
+ mdiPlus,
784
+ } from '@mdi/js';
785
+ import CircularProgress from '@smui/circular-progress';
786
+ import Tab from '@smui/tab';
787
+ import TabBar from '@smui/tab-bar';
788
+ import LayoutGrid, { Cell as LayoutCell } from '@smui/layout-grid';
789
+ import FormField from '@smui/form-field';
790
+ import Checkbox from '@smui/checkbox';
791
+ import List, { Item, Text, Meta } from '@smui/list';
792
+ import Paper from '@smui/paper';
793
+ import DataTable, { Head, Body, Row, Cell } from '@smui/data-table';
794
+ import Textfield, { Input } from '@smui/textfield';
795
+ import HelperText from '@smui/textfield/helper-text';
796
+ import IconButton from '@smui/icon-button';
797
+ import Button from '@smui/button';
798
+ import Dialog, { Title, Content, Actions } from '@smui/dialog';
799
+ import { Icon, Label } from '@smui/common';
800
+
801
+ import { User, Group } from '../nymph';
802
+
803
+ let {
804
+ router,
805
+ params,
806
+ clientConfig,
807
+ user,
808
+ }: {
809
+ router: Navigo;
810
+ params: { guid: string };
811
+ clientConfig: Writable<ClientConfig | undefined>;
812
+ user: Writable<(UserClass & CurrentUserData) | null | undefined>;
813
+ } = $props();
814
+
815
+ let entity: Writable<UserClass & AdminUserData> = writable(
816
+ User.factorySync(),
817
+ );
818
+ let sysAdmin = $state(false);
819
+ let tilmeldSwitchUser = $state(false);
820
+ let tilmeldSwitchUserDialogOpen = $state(false);
821
+ let activeTab: 'General' | 'Groups' | 'Abilities' | 'Security' =
822
+ $state('General');
823
+ let primaryGroupSearch = $state('');
824
+ let secondaryGroupSearch = $state('');
825
+ let ability = $state('');
826
+ let avatar = $state('https://secure.gravatar.com/avatar/?d=mm&s=40');
827
+ let hasTOTPSecret: boolean | undefined = $state();
828
+ let failureMessage: string | undefined = $state();
829
+ let passwordVerify = $state('');
830
+ let passwordVerified: boolean | undefined = $state();
831
+ let usernameTimer: NodeJS.Timeout | undefined = undefined;
832
+ let usernameVerified: boolean | undefined = $state();
833
+ let usernameVerifiedMessage: string | undefined = $state();
834
+ let emailTimer: NodeJS.Timeout | undefined = undefined;
835
+ let emailVerified: boolean | undefined = $state();
836
+ let emailVerifiedMessage: string | undefined = $state();
837
+ let saving = $state(false);
838
+ let success: boolean | undefined = $state();
839
+ let loading = $state(true);
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
+ if (await $entity.$save()) {
1142
+ await readyEntity();
1143
+ success = true;
1144
+ passwordVerify = '';
1145
+ if (newEntity) {
1146
+ router.navigate(
1147
+ `/users/edit/${encodeURIComponent($entity.guid || '')}`,
1148
+ { historyAPIMethod: 'replaceState' },
1149
+ );
1150
+ }
1151
+ setTimeout(() => {
1152
+ success = undefined;
1153
+ }, 1000);
1154
+ } else {
1155
+ failureMessage = 'Error saving user.';
1156
+ }
1157
+ } catch (e: any) {
1158
+ console.log('error:', e);
1159
+ failureMessage = e?.message;
1160
+ }
1161
+ saving = false;
1162
+ }
1163
+
1164
+ async function deleteEntity() {
1165
+ failureMessage = undefined;
1166
+ if (confirm('Are you sure you want to delete this?')) {
1167
+ saving = true;
1168
+ try {
1169
+ if (await $entity.$delete()) {
1170
+ router.navigate('', { historyAPIMethod: 'back' });
1171
+ } else {
1172
+ failureMessage = 'An error occurred.';
1173
+ }
1174
+ } catch (e: any) {
1175
+ failureMessage = e?.message;
1176
+ }
1177
+ saving = false;
1178
+ }
1179
+ }
1180
+
1181
+ async function switchUserDialogCloseHandler(
1182
+ e: CustomEvent<{ action: string }>,
1183
+ ) {
1184
+ if (e.detail.action === 'switch') {
1185
+ saving = true;
1186
+ try {
1187
+ const result = await $entity.$switchUser();
1188
+
1189
+ if (result.result) {
1190
+ window.location.href =
1191
+ (window as unknown as { appUrl: string }).appUrl ||
1192
+ window.location.href;
1193
+ } else {
1194
+ failureMessage = result.message;
1195
+ }
1196
+ } catch (e: any) {
1197
+ failureMessage = e?.message;
1198
+ }
1199
+ saving = false;
1200
+ }
1201
+ }
1202
+ </script>