@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.
- package/CHANGELOG.md +442 -0
- package/README.md +6 -5
- package/app/index.ts +9 -3
- package/app/src/App.svelte +143 -71
- package/app/src/nymph.ts +1 -1
- package/app/src/routes/GroupEdit.svelte +638 -0
- package/app/src/routes/Groups.svelte +201 -0
- package/app/src/{Intro.svelte → routes/Intro.svelte} +14 -1
- package/app/src/routes/NotFound.svelte +9 -0
- package/app/src/routes/UserEdit.svelte +1202 -0
- package/app/src/routes/Users.svelte +201 -0
- package/dist/app/index.css +105 -0
- package/dist/app/index.d.ts +1 -0
- package/dist/app/index.js +41288 -3
- package/dist/app/smui.css +1 -29
- package/dist/app/src/nymph.d.ts +6 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +19 -18
- package/dist/index.js.map +1 -1
- package/jest.config.js +11 -2
- package/package.json +58 -54
- package/rollup.config.mjs +36 -0
- package/src/index.ts +28 -21
- package/src/type/locutus.d.ts +3 -0
- package/static/index.html +4 -1
- package/test.mjs +13 -12
- package/tsconfig.json +11 -1
- package/tsconfig.server.json +5 -3
- package/typedoc.json +5 -0
- package/app/src/GroupEdit.svelte +0 -570
- package/app/src/Groups.svelte +0 -159
- package/app/src/UserEdit.svelte +0 -902
- package/app/src/Users.svelte +0 -159
- package/dist/app/index.js.LICENSE.txt +0 -114
- package/dist/app/index.js.map +0 -1
- package/webpack.config.js +0 -38
|
@@ -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>
|