@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,638 @@
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 ? ($entity.name ?? $entity.groupname) : 'New Group'}
19
+ </h2>
20
+ </div>
21
+
22
+ {#if $entity.user}
23
+ {#await $entity.user.$wake() then _user}
24
+ <div style="padding: 12px;" class="mdc-typography--subtitle1">
25
+ Generated primary group for <a
26
+ href="#/users/edit/{encodeURIComponent($entity.user.guid || '')}"
27
+ >{$clientConfig.userFields.includes('name')
28
+ ? $entity.user.name + ' (' + $entity.user.username + ')'
29
+ : $entity.user.username}</a
30
+ >
31
+ </div>
32
+ {/await}
33
+ {/if}
34
+
35
+ <TabBar tabs={['General', 'Parent', 'Abilities']} bind:active={activeTab}>
36
+ {#snippet tab(tab)}
37
+ <Tab {tab}>
38
+ <Label>{tab}</Label>
39
+ </Tab>
40
+ {/snippet}
41
+ </TabBar>
42
+
43
+ <section>
44
+ {#if activeTab === 'General'}
45
+ <LayoutGrid style="padding: 0;">
46
+ {#if $entity.user != null}
47
+ <LayoutCell span={12}>
48
+ Some of these fields are not editable, since this group inherits the
49
+ values from its user.
50
+ </LayoutCell>
51
+ {/if}
52
+ <LayoutCell span={4}>
53
+ <div class="mdc-typography--headline6">GUID</div>
54
+ <code>{$entity.guid}</code>
55
+ </LayoutCell>
56
+ <LayoutCell span={4}>
57
+ <FormField>
58
+ <Checkbox bind:checked={$entity.enabled} />
59
+ {#snippet label()}
60
+ Enabled (Able to give abilities)
61
+ {/snippet}
62
+ </FormField>
63
+ </LayoutCell>
64
+ <LayoutCell span={4} style="text-align: end;">
65
+ <a href="https://en.gravatar.com/" target="_blank" rel="noreferrer">
66
+ <img src={avatar} alt="Avatar" title="Avatar by Gravatar" />
67
+ </a>
68
+ </LayoutCell>
69
+ {#if !$clientConfig.emailUsernames}
70
+ <LayoutCell
71
+ span={$clientConfig.userFields.includes('email') ? 6 : 12}
72
+ >
73
+ <Textfield
74
+ bind:value={$entity.groupname}
75
+ label="Groupname"
76
+ type="text"
77
+ style="width: 100%;"
78
+ helperLine$style="width: 100%;"
79
+ invalid={groupnameVerified === false}
80
+ input$autocomplete="off"
81
+ input$autocapitalize="off"
82
+ input$spellcheck="false"
83
+ disabled={$entity.user != null}
84
+ >
85
+ {#snippet helper()}
86
+ <HelperText persistent>
87
+ {groupnameVerifiedMessage ?? ''}
88
+ </HelperText>
89
+ {/snippet}
90
+ </Textfield>
91
+ </LayoutCell>
92
+ {/if}
93
+ {#if $clientConfig.userFields.includes('email')}
94
+ <LayoutCell span={$clientConfig.emailUsernames ? 12 : 6}>
95
+ <Textfield
96
+ bind:value={$entity.email}
97
+ label="Email"
98
+ type="email"
99
+ style="width: 100%;"
100
+ helperLine$style="width: 100%;"
101
+ invalid={emailVerified === false}
102
+ input$autocomplete="off"
103
+ input$autocapitalize="off"
104
+ input$spellcheck="false"
105
+ disabled={$entity.user != null}
106
+ >
107
+ {#snippet helper()}
108
+ <HelperText persistent>
109
+ {emailVerifiedMessage ?? ''}
110
+ </HelperText>
111
+ {/snippet}
112
+ </Textfield>
113
+ </LayoutCell>
114
+ {/if}
115
+ {#if $clientConfig.userFields.includes('name')}
116
+ <LayoutCell span={12}>
117
+ <Textfield
118
+ bind:value={$entity.name}
119
+ label="Display Name"
120
+ type="text"
121
+ style="width: 100%;"
122
+ input$autocomplete="off"
123
+ disabled={$entity.user != null}
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
+ disabled={$entity.user != null}
135
+ />
136
+ </LayoutCell>
137
+ {#if $clientConfig.userFields.includes('phone')}
138
+ <LayoutCell span={4}>
139
+ <Textfield
140
+ bind:value={$entity.phone}
141
+ label="Phone"
142
+ type="tel"
143
+ style="width: 100%;"
144
+ input$autocomplete="off"
145
+ disabled={$entity.user != null}
146
+ />
147
+ </LayoutCell>
148
+ {/if}
149
+ <LayoutCell span={12}>
150
+ <FormField>
151
+ <Checkbox bind:checked={$entity.defaultPrimary} />
152
+ {#snippet label()}
153
+ Default primary group for newly registered users. <small
154
+ class="form-text text-muted"
155
+ >Setting this will unset any current default primary group.</small
156
+ >
157
+ {/snippet}
158
+ </FormField>
159
+ </LayoutCell>
160
+ <LayoutCell span={12}>
161
+ <FormField>
162
+ <Checkbox bind:checked={$entity.defaultSecondary} />
163
+ {#snippet label()}
164
+ Default secondary group for newly registered{$clientConfig?.userFields.includes(
165
+ 'email',
166
+ ) &&
167
+ $clientConfig.verifyEmail &&
168
+ $clientConfig.unverifiedAccess
169
+ ? ', verified'
170
+ : ''} users.
171
+ {/snippet}
172
+ </FormField>
173
+ </LayoutCell>
174
+ {#if $clientConfig.userFields.includes('email') && $clientConfig.verifyEmail && $clientConfig.unverifiedAccess}
175
+ <LayoutCell span={12}>
176
+ <FormField>
177
+ <Checkbox bind:checked={$entity.unverifiedSecondary} />
178
+ {#snippet label()}
179
+ Default secondary group for newly registered, unverified users.
180
+ {/snippet}
181
+ </FormField>
182
+ </LayoutCell>
183
+ {/if}
184
+ </LayoutGrid>
185
+ {/if}
186
+
187
+ {#if activeTab === 'Parent'}
188
+ <h5 style="margin-top: 0;">Parent</h5>
189
+
190
+ <Paper
191
+ style="display: flex; justify-content: space-between; align-items: center;"
192
+ >
193
+ {#if !$entity.parent}
194
+ No parent
195
+ {:else}
196
+ <a
197
+ href="#/groups/edit/{encodeURIComponent($entity.parent.guid || '')}"
198
+ >{$clientConfig.userFields.includes('name')
199
+ ? $entity.parent.name + ' (' + $entity.parent.groupname + ')'
200
+ : $entity.parent.groupname}</a
201
+ >
202
+
203
+ <IconButton
204
+ onclick={() => {
205
+ delete $entity.parent;
206
+ $entity = $entity;
207
+ }}
208
+ >
209
+ <Icon tag="svg" viewBox="0 0 24 24">
210
+ <path fill="currentColor" d={mdiMinus} />
211
+ </Icon>
212
+ </IconButton>
213
+ {/if}
214
+ </Paper>
215
+
216
+ <h6>Change Parent</h6>
217
+
218
+ <div class="solo-search-container solo-container">
219
+ <Paper class="solo-paper" elevation={1}>
220
+ <Icon class="solo-icon" tag="svg" viewBox="0 0 24 24">
221
+ <path fill="currentColor" d={mdiMagnify} />
222
+ </Icon>
223
+ <Input
224
+ bind:value={parentSearch}
225
+ onkeydown={parentSearchKeyDown}
226
+ placeholder="Parent Search"
227
+ class="solo-input"
228
+ />
229
+ </Paper>
230
+ <IconButton
231
+ onclick={searchParents}
232
+ disabled={parentSearch === ''}
233
+ class="solo-fab"
234
+ title="Search"
235
+ >
236
+ <Icon tag="svg" viewBox="0 0 24 24">
237
+ <path fill="currentColor" d={mdiArrowRight} />
238
+ </Icon>
239
+ </IconButton>
240
+ </div>
241
+
242
+ {#if parentsSearching}
243
+ <div
244
+ style="display: flex; justify-content: center; align-items: center;"
245
+ >
246
+ <CircularProgress style="height: 32px; width: 32px;" indeterminate />
247
+ </div>
248
+ {:else if parents != null}
249
+ <DataTable table$aria-label="Parent list" style="width: 100%;">
250
+ <Head>
251
+ <Row>
252
+ {#if !$clientConfig.emailUsernames}
253
+ <Cell>Groupname</Cell>
254
+ {/if}
255
+ {#if $clientConfig.userFields.includes('name')}
256
+ <Cell>Name</Cell>
257
+ {/if}
258
+ {#if $clientConfig.userFields.includes('email')}
259
+ <Cell>Email</Cell>
260
+ {/if}
261
+ <Cell>Enabled</Cell>
262
+ </Row>
263
+ </Head>
264
+ <Body>
265
+ <!-- Purposefully not making these links. -->
266
+ {#each parents as curEntity (curEntity.guid)}
267
+ <Row
268
+ onclick={() => ($entity.parent = curEntity)}
269
+ style="cursor: pointer;"
270
+ >
271
+ {#if !$clientConfig.emailUsernames}
272
+ <Cell>{curEntity.groupname}</Cell>
273
+ {/if}
274
+ {#if $clientConfig.userFields.includes('name')}
275
+ <Cell>{curEntity.name}</Cell>
276
+ {/if}
277
+ {#if $clientConfig.userFields.includes('email')}
278
+ <Cell>{curEntity.email}</Cell>
279
+ {/if}
280
+ <Cell>{curEntity.enabled ? 'Yes' : 'No'}</Cell>
281
+ </Row>
282
+ {/each}
283
+ </Body>
284
+ </DataTable>
285
+ {/if}
286
+ {/if}
287
+
288
+ {#if activeTab === 'Abilities'}
289
+ <h5 style="margin-top: 0;">Abilities</h5>
290
+
291
+ <List nonInteractive>
292
+ {#each $entity.abilities || [] as ability, index (ability)}
293
+ <Item>
294
+ <Text>
295
+ {ability}
296
+ </Text>
297
+ <Meta>
298
+ <IconButton
299
+ onclick={() => {
300
+ $entity.abilities?.splice(index, 1);
301
+ $entity = $entity;
302
+ }}
303
+ >
304
+ <Icon tag="svg" viewBox="0 0 24 24">
305
+ <path fill="currentColor" d={mdiMinus} />
306
+ </Icon>
307
+ </IconButton>
308
+ </Meta>
309
+ </Item>
310
+ {:else}
311
+ <Item>
312
+ <Text>No abilities</Text>
313
+ </Item>
314
+ {/each}
315
+ </List>
316
+
317
+ <h6>Add Ability</h6>
318
+
319
+ <div style="display: flex; align-items: center; flex-wrap: wrap;">
320
+ <Textfield
321
+ bind:value={ability}
322
+ label="Ability"
323
+ type="text"
324
+ style="width: 250px; max-width: 100%;"
325
+ onkeydown={abilityKeyDown}
326
+ />
327
+ <IconButton onclick={addAbility}>
328
+ <Icon tag="svg" viewBox="0 0 24 24">
329
+ <path fill="currentColor" d={mdiPlus} />
330
+ </Icon>
331
+ </IconButton>
332
+ </div>
333
+ {/if}
334
+
335
+ {#if failureMessage}
336
+ <div class="tilmeld-failure">
337
+ {failureMessage}
338
+ </div>
339
+ {/if}
340
+
341
+ <div style="margin-top: 36px;">
342
+ <Button variant="raised" onclick={saveEntity} disabled={saving}>
343
+ <Label>Save Group</Label>
344
+ </Button>
345
+ {#if $entity.guid}
346
+ <Button onclick={deleteEntity} disabled={saving}>
347
+ <Label>Delete</Label>
348
+ </Button>
349
+ {/if}
350
+ {#if success}
351
+ <span>Successfully saved!</span>
352
+ {/if}
353
+ </div>
354
+ </section>
355
+ {/if}
356
+
357
+ <script lang="ts">
358
+ import type { Writable } from 'svelte/store';
359
+ import { writable } from 'svelte/store';
360
+ import type Navigo from 'navigo';
361
+ import type {
362
+ AdminGroupData,
363
+ ClientConfig,
364
+ CurrentUserData,
365
+ } from '@nymphjs/tilmeld-client';
366
+ import type {
367
+ Group as GroupClass,
368
+ User as UserClass,
369
+ } from '@nymphjs/tilmeld-client';
370
+ import queryParser from '@nymphjs/query-parser';
371
+ import {
372
+ mdiArrowLeft,
373
+ mdiArrowRight,
374
+ mdiMagnify,
375
+ mdiMinus,
376
+ mdiPlus,
377
+ } from '@mdi/js';
378
+ import CircularProgress from '@smui/circular-progress';
379
+ import Tab from '@smui/tab';
380
+ import TabBar from '@smui/tab-bar';
381
+ import LayoutGrid, { Cell as LayoutCell } from '@smui/layout-grid';
382
+ import FormField from '@smui/form-field';
383
+ import Checkbox from '@smui/checkbox';
384
+ import List, { Item, Text, Meta } from '@smui/list';
385
+ import Paper from '@smui/paper';
386
+ import DataTable, { Head, Body, Row, Cell } from '@smui/data-table';
387
+ import Textfield, { Input } from '@smui/textfield';
388
+ import HelperText from '@smui/textfield/helper-text';
389
+ import IconButton from '@smui/icon-button';
390
+ import Button from '@smui/button';
391
+ import { Icon, Label } from '@smui/common';
392
+
393
+ import { nymph, Group, User } from '../nymph';
394
+
395
+ let {
396
+ router,
397
+ params,
398
+ clientConfig,
399
+ user,
400
+ }: {
401
+ router: Navigo;
402
+ params: { guid: string };
403
+ clientConfig: Writable<ClientConfig | undefined>;
404
+ user: Writable<(UserClass & CurrentUserData) | null | undefined>;
405
+ } = $props();
406
+
407
+ let entity: Writable<GroupClass & AdminGroupData> = writable(
408
+ Group.factorySync(),
409
+ );
410
+ let activeTab: 'General' | 'Parent' | 'Abilities' = $state('General');
411
+ let parentSearch = $state('');
412
+ let ability = $state('');
413
+ let avatar = $state('https://secure.gravatar.com/avatar/?d=mm&s=40');
414
+ let failureMessage: string | undefined = $state();
415
+ let groupnameTimer: NodeJS.Timeout | undefined = undefined;
416
+ let groupnameVerified: boolean | undefined = $state();
417
+ let groupnameVerifiedMessage: string | undefined = $state();
418
+ let emailTimer: NodeJS.Timeout | undefined = undefined;
419
+ let emailVerified: boolean | undefined = $state();
420
+ let emailVerifiedMessage: string | undefined = $state();
421
+ let saving = $state(false);
422
+ let success: boolean | undefined = $state();
423
+ let loading = $state(true);
424
+
425
+ $effect(() => {
426
+ if (params) {
427
+ handleGuidParam();
428
+ }
429
+ });
430
+
431
+ async function handleGuidParam() {
432
+ loading = true;
433
+ failureMessage = undefined;
434
+ try {
435
+ $entity =
436
+ params.guid === '+' || params.guid === ' ' || params.guid === '%20'
437
+ ? await Group.factory()
438
+ : await Group.factory(params.guid);
439
+ oldGroupname = $entity.groupname;
440
+ oldEmail = $entity.email;
441
+ await readyEntity();
442
+ } catch (e: any) {
443
+ failureMessage = e.message;
444
+ }
445
+ loading = false;
446
+ }
447
+
448
+ async function readyEntity() {
449
+ // Make sure all fields are defined.
450
+ if ($entity.enabled == null) {
451
+ $entity.enabled = false;
452
+ }
453
+ if ($entity.groupname == null) {
454
+ $entity.groupname = '';
455
+ }
456
+ if ($entity.email == null) {
457
+ $entity.email = '';
458
+ }
459
+ if ($entity.name == null) {
460
+ $entity.name = '';
461
+ }
462
+ if ($entity.avatar == null) {
463
+ $entity.avatar = '';
464
+ }
465
+ if ($entity.phone == null) {
466
+ $entity.phone = '';
467
+ }
468
+ if ($entity.defaultPrimary == null) {
469
+ $entity.defaultPrimary = false;
470
+ }
471
+ if ($entity.defaultSecondary == null) {
472
+ $entity.defaultSecondary = false;
473
+ }
474
+ if ($entity.unverifiedSecondary == null) {
475
+ $entity.unverifiedSecondary = false;
476
+ }
477
+ avatar = await $entity.$getAvatar();
478
+ await $entity.$wakeAll(1);
479
+ $entity = $entity;
480
+ }
481
+
482
+ let parentsSearching = $state(false);
483
+ let parents: (GroupClass & AdminGroupData)[] | undefined = $state();
484
+ async function searchParents() {
485
+ parentsSearching = true;
486
+ failureMessage = undefined;
487
+ if (parentSearch.trim() == '') {
488
+ return;
489
+ }
490
+ try {
491
+ const query = queryParser({
492
+ query: parentSearch,
493
+ entityClass: Group,
494
+ defaultFields: ['groupname', 'name', 'email'],
495
+ qrefMap: {
496
+ User: {
497
+ class: User,
498
+ defaultFields: ['username', 'name', 'email'],
499
+ },
500
+ Group: {
501
+ class: Group,
502
+ defaultFields: ['groupname', 'name', 'email'],
503
+ },
504
+ },
505
+ });
506
+ parents = (await nymph.getEntities(...query)).filter((group) => {
507
+ return !group.$is($entity) && !group.$is($entity.parent);
508
+ });
509
+ } catch (e: any) {
510
+ failureMessage = e?.message;
511
+ }
512
+ parentsSearching = false;
513
+ }
514
+ function parentSearchKeyDown(event: CustomEvent | KeyboardEvent) {
515
+ event = event as KeyboardEvent;
516
+ if (event.key === 'Enter') searchParents();
517
+ }
518
+
519
+ let oldGroupname: string | undefined = undefined;
520
+ $effect(() => {
521
+ if ($entity && $entity.groupname !== oldGroupname) {
522
+ if (groupnameTimer) {
523
+ clearTimeout(groupnameTimer);
524
+ }
525
+ groupnameTimer = setTimeout(async () => {
526
+ if ($entity.groupname === '') {
527
+ groupnameVerified = undefined;
528
+ groupnameVerifiedMessage = undefined;
529
+ return;
530
+ }
531
+ try {
532
+ const data = await $entity.$checkGroupname();
533
+ groupnameVerified = data.result;
534
+ groupnameVerifiedMessage = data.message;
535
+ } catch (e: any) {
536
+ groupnameVerified = false;
537
+ groupnameVerifiedMessage = e?.message;
538
+ }
539
+ }, 400);
540
+ oldGroupname = $entity.groupname;
541
+ }
542
+ });
543
+
544
+ let oldEmail: string | undefined = undefined;
545
+ $effect(() => {
546
+ if ($entity && $entity.email !== oldEmail) {
547
+ if (emailTimer) {
548
+ clearTimeout(emailTimer);
549
+ }
550
+ emailTimer = setTimeout(async () => {
551
+ if ($entity.email === '') {
552
+ emailVerified = undefined;
553
+ emailVerifiedMessage = undefined;
554
+ return;
555
+ }
556
+ try {
557
+ const data = await $entity.$checkEmail();
558
+ emailVerified = data.result;
559
+ emailVerifiedMessage = data.message;
560
+ } catch (e: any) {
561
+ emailVerified = false;
562
+ emailVerifiedMessage = e?.message;
563
+ }
564
+ }, 400);
565
+ oldEmail = $entity.email;
566
+ }
567
+ });
568
+
569
+ function addAbility() {
570
+ if (ability === '') {
571
+ return;
572
+ }
573
+ failureMessage = undefined;
574
+ if (ability === 'system/admin') {
575
+ failureMessage = "Groups aren't allowed to be system admins.";
576
+ return;
577
+ }
578
+ if (ability === 'tilmeld/admin') {
579
+ failureMessage = "Groups aren't allowed to be Tilmeld admins.";
580
+ return;
581
+ }
582
+ if (ability === 'tilmeld/switch') {
583
+ failureMessage = "Groups aren't allowed to have switch user ability.";
584
+ return;
585
+ }
586
+ $entity.abilities?.push(ability);
587
+ $entity = $entity;
588
+ ability = '';
589
+ }
590
+ function abilityKeyDown(event: CustomEvent | KeyboardEvent) {
591
+ event = event as KeyboardEvent;
592
+ if (event.key === 'Enter') addAbility();
593
+ }
594
+
595
+ async function saveEntity() {
596
+ saving = true;
597
+ failureMessage = undefined;
598
+ const newEntity = $entity.guid == null;
599
+ try {
600
+ if (await $entity.$save()) {
601
+ await readyEntity();
602
+ success = true;
603
+ if (newEntity) {
604
+ router.navigate(
605
+ `/groups/edit/${encodeURIComponent($entity.guid || '')}`,
606
+ { historyAPIMethod: 'replaceState' },
607
+ );
608
+ }
609
+ setTimeout(() => {
610
+ success = undefined;
611
+ }, 1000);
612
+ } else {
613
+ failureMessage = 'Error saving group.';
614
+ }
615
+ } catch (e: any) {
616
+ console.log('error:', e);
617
+ failureMessage = e?.message;
618
+ }
619
+ saving = false;
620
+ }
621
+
622
+ async function deleteEntity() {
623
+ failureMessage = undefined;
624
+ if (confirm('Are you sure you want to delete this?')) {
625
+ saving = true;
626
+ try {
627
+ if (await $entity.$delete()) {
628
+ router.navigate('', { historyAPIMethod: 'back' });
629
+ } else {
630
+ failureMessage = 'An error occurred.';
631
+ }
632
+ } catch (e: any) {
633
+ failureMessage = e?.message;
634
+ }
635
+ saving = false;
636
+ }
637
+ }
638
+ </script>