@makolabs/ripple 3.0.10 → 3.1.0
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/dist/funcs/mock-user-management.d.ts +41 -0
- package/dist/funcs/mock-user-management.js +85 -1
- package/dist/funcs/user-management.remote.d.ts +23 -0
- package/dist/funcs/user-management.remote.js +128 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -0
- package/dist/user-management/ApiKeyField.svelte +165 -0
- package/dist/user-management/ApiKeyField.svelte.d.ts +32 -0
- package/dist/user-management/RoleCard.svelte +73 -0
- package/dist/user-management/RoleCard.svelte.d.ts +16 -0
- package/dist/user-management/UserApproveModal.svelte +120 -0
- package/dist/user-management/UserApproveModal.svelte.d.ts +4 -0
- package/dist/user-management/UserIdentityCard.svelte +53 -0
- package/dist/user-management/UserIdentityCard.svelte.d.ts +11 -0
- package/dist/user-management/UserManagement.svelte +186 -19
- package/dist/user-management/UserModal.svelte +202 -437
- package/dist/user-management/UserTable.svelte +48 -55
- package/dist/user-management/UserViewModal.svelte +87 -221
- package/dist/user-management/UserViewModal.svelte.d.ts +1 -1
- package/dist/user-management/user-management-types.d.ts +57 -3
- package/package.json +1 -1
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
import {
|
|
3
3
|
Modal,
|
|
4
4
|
Button,
|
|
5
|
+
Input,
|
|
6
|
+
Color,
|
|
5
7
|
cn,
|
|
6
8
|
type User,
|
|
7
9
|
type UserModalProps,
|
|
8
10
|
type FormErrors,
|
|
9
|
-
type Role
|
|
10
|
-
getUserDisplayName
|
|
11
|
+
type Role
|
|
11
12
|
} from '../index.js';
|
|
13
|
+
import RoleCard from './RoleCard.svelte';
|
|
14
|
+
import ApiKeyField from './ApiKeyField.svelte';
|
|
15
|
+
import UserIdentityCard from './UserIdentityCard.svelte';
|
|
12
16
|
import { toast } from 'svelte-sonner';
|
|
13
17
|
|
|
14
|
-
// Icons as simple SVGs
|
|
15
18
|
let {
|
|
16
19
|
open = $bindable(),
|
|
17
20
|
user = $bindable(),
|
|
@@ -23,14 +26,11 @@
|
|
|
23
26
|
class: className
|
|
24
27
|
}: UserModalProps = $props();
|
|
25
28
|
|
|
26
|
-
// Mode determination
|
|
27
29
|
const mode = $derived(user ? 'edit' : 'create');
|
|
28
30
|
|
|
29
|
-
// Local state
|
|
30
31
|
let formErrors = $state<FormErrors>({});
|
|
31
32
|
let saving = $state(false);
|
|
32
33
|
let formElement = $state<HTMLFormElement | null>(null);
|
|
33
|
-
let showApiKey = $state(false);
|
|
34
34
|
let regeneratingApiKey = $state(false);
|
|
35
35
|
let verifyingToken = $state(false);
|
|
36
36
|
let tokenVerification = $state<{
|
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
} | null>(null);
|
|
43
43
|
let initialRole = $state<string>('');
|
|
44
44
|
|
|
45
|
-
// Form data
|
|
46
45
|
let formData = $state<Partial<User>>({
|
|
47
46
|
first_name: '',
|
|
48
47
|
last_name: '',
|
|
@@ -52,68 +51,36 @@
|
|
|
52
51
|
permissions: []
|
|
53
52
|
});
|
|
54
53
|
|
|
55
|
-
// Helper function to detect role from permissions
|
|
56
54
|
function detectRoleFromPermissions(userPermissions: string[]): string {
|
|
57
|
-
if (!roles
|
|
58
|
-
return '';
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// First, try to find an exact match (all role permissions match user permissions)
|
|
55
|
+
if (!roles?.length || !userPermissions?.length) return '';
|
|
62
56
|
for (const role of roles) {
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
);
|
|
69
|
-
// Also check if the counts match (to avoid partial matches)
|
|
70
|
-
if (allPermissionsMatch && rolePermissions.length === userPermissions.length) {
|
|
71
|
-
return role.value;
|
|
72
|
-
}
|
|
57
|
+
if (
|
|
58
|
+
role.permissions?.length === userPermissions.length &&
|
|
59
|
+
role.permissions.every((p) => userPermissions.includes(p))
|
|
60
|
+
) {
|
|
61
|
+
return role.value;
|
|
73
62
|
}
|
|
74
63
|
}
|
|
75
|
-
|
|
76
|
-
// If no exact match, try to find a role where all of its permissions are in user's permissions
|
|
77
|
-
// This handles cases where user has additional permissions beyond the role
|
|
78
64
|
for (const role of roles) {
|
|
79
|
-
if (role.permissions && role.permissions.
|
|
80
|
-
|
|
81
|
-
userPermissions.includes(perm)
|
|
82
|
-
);
|
|
83
|
-
if (allRolePermissionsInUser) {
|
|
84
|
-
return role.value;
|
|
85
|
-
}
|
|
65
|
+
if (role.permissions?.length && role.permissions.every((p) => userPermissions.includes(p))) {
|
|
66
|
+
return role.value;
|
|
86
67
|
}
|
|
87
68
|
}
|
|
88
|
-
|
|
89
|
-
// Last resort: find any role where at least one permission matches
|
|
90
|
-
// (for cases where permissions might be partially matching)
|
|
91
69
|
for (const role of roles) {
|
|
92
|
-
if (role.permissions
|
|
93
|
-
|
|
94
|
-
userPermissions.includes(perm)
|
|
95
|
-
);
|
|
96
|
-
if (hasMatchingPermission) {
|
|
97
|
-
return role.value;
|
|
98
|
-
}
|
|
70
|
+
if (role.permissions?.some((p) => userPermissions.includes(p))) {
|
|
71
|
+
return role.value;
|
|
99
72
|
}
|
|
100
73
|
}
|
|
101
|
-
|
|
102
74
|
return '';
|
|
103
75
|
}
|
|
104
76
|
|
|
105
|
-
// Initialize form data when user changes
|
|
106
77
|
$effect(() => {
|
|
107
78
|
if (open && user) {
|
|
108
|
-
// Detect role from permissions if role is not already set
|
|
109
79
|
let detectedRole = user.role || '';
|
|
110
|
-
if (!detectedRole && user.permissions
|
|
80
|
+
if (!detectedRole && user.permissions?.length) {
|
|
111
81
|
detectedRole = detectRoleFromPermissions(user.permissions);
|
|
112
82
|
}
|
|
113
|
-
|
|
114
|
-
// Store the initial role from server data
|
|
115
83
|
initialRole = detectedRole;
|
|
116
|
-
|
|
117
84
|
formData = {
|
|
118
85
|
first_name: user.first_name || '',
|
|
119
86
|
last_name: user.last_name || '',
|
|
@@ -123,7 +90,6 @@
|
|
|
123
90
|
permissions: user.permissions || []
|
|
124
91
|
};
|
|
125
92
|
} else if (open && !user) {
|
|
126
|
-
// Reset for create mode
|
|
127
93
|
initialRole = '';
|
|
128
94
|
formData = {
|
|
129
95
|
first_name: '',
|
|
@@ -149,20 +115,15 @@
|
|
|
149
115
|
event.preventDefault();
|
|
150
116
|
formErrors = {};
|
|
151
117
|
|
|
152
|
-
|
|
153
|
-
if (!formData.
|
|
154
|
-
formErrors.first_name = 'First name is required';
|
|
155
|
-
}
|
|
156
|
-
if (!formData.last_name?.trim()) {
|
|
157
|
-
formErrors.last_name = 'Last name is required';
|
|
158
|
-
}
|
|
118
|
+
if (!formData.first_name?.trim()) formErrors.first_name = 'First name is required';
|
|
119
|
+
if (!formData.last_name?.trim()) formErrors.last_name = 'Last name is required';
|
|
159
120
|
if (!formData.email_addresses?.[0]?.email_address?.trim()) {
|
|
160
121
|
formErrors.email = 'Email address is required';
|
|
161
122
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return;
|
|
123
|
+
if (mode === 'create' && roles.length > 0 && !formData.role?.trim()) {
|
|
124
|
+
formErrors.role = 'Role is required';
|
|
165
125
|
}
|
|
126
|
+
if (Object.keys(formErrors).length > 0) return;
|
|
166
127
|
|
|
167
128
|
try {
|
|
168
129
|
saving = true;
|
|
@@ -183,45 +144,33 @@
|
|
|
183
144
|
handleClose();
|
|
184
145
|
} catch (error) {
|
|
185
146
|
console.error('[UserModal] Error saving user:', error);
|
|
186
|
-
|
|
147
|
+
const message = error instanceof Error ? error.message : 'Failed to save user';
|
|
148
|
+
toast.error(message);
|
|
149
|
+
formErrors.submit = message;
|
|
187
150
|
} finally {
|
|
188
151
|
saving = false;
|
|
189
152
|
}
|
|
190
153
|
}
|
|
191
154
|
|
|
192
|
-
function handleRoleChange(
|
|
193
|
-
const role = roles?.find((r: Role) => r.value === roleValue);
|
|
194
|
-
|
|
195
|
-
// Reassign entire formData object to ensure Svelte 5 reactivity
|
|
155
|
+
function handleRoleChange(role: Role) {
|
|
196
156
|
formData = {
|
|
197
157
|
...formData,
|
|
198
|
-
role:
|
|
199
|
-
permissions:
|
|
158
|
+
role: role.value,
|
|
159
|
+
permissions: [...role.permissions]
|
|
200
160
|
};
|
|
201
|
-
|
|
202
|
-
// Clear token verification when permissions change
|
|
203
161
|
tokenVerification = null;
|
|
204
162
|
}
|
|
205
163
|
|
|
206
164
|
async function handleRegenerateApiKey() {
|
|
207
|
-
if (
|
|
208
|
-
!user?.id ||
|
|
209
|
-
!adapter?.generateApiKey ||
|
|
210
|
-
!formData.permissions ||
|
|
211
|
-
formData.permissions.length === 0
|
|
212
|
-
)
|
|
213
|
-
return;
|
|
214
|
-
|
|
165
|
+
if (!user?.id || !adapter?.generateApiKey || !formData.permissions?.length) return;
|
|
215
166
|
try {
|
|
216
167
|
regeneratingApiKey = true;
|
|
217
|
-
tokenVerification = null;
|
|
168
|
+
tokenVerification = null;
|
|
218
169
|
const result = await adapter.generateApiKey({
|
|
219
170
|
userId: user.id,
|
|
220
171
|
permissions: formData.permissions,
|
|
221
172
|
revokeOld: true
|
|
222
173
|
});
|
|
223
|
-
|
|
224
|
-
// Update user's private_metadata with new API key
|
|
225
174
|
if (user && result.apiKey) {
|
|
226
175
|
user = {
|
|
227
176
|
...user,
|
|
@@ -231,12 +180,10 @@
|
|
|
231
180
|
}
|
|
232
181
|
};
|
|
233
182
|
}
|
|
234
|
-
|
|
235
|
-
// Show warning toast if new key verification failed
|
|
236
183
|
if (result.verificationWarning) {
|
|
237
184
|
toast.warning('API Key Verification Warning', {
|
|
238
185
|
description: result.verificationWarning,
|
|
239
|
-
duration: 8000
|
|
186
|
+
duration: 8000
|
|
240
187
|
});
|
|
241
188
|
}
|
|
242
189
|
} catch (error) {
|
|
@@ -249,13 +196,11 @@
|
|
|
249
196
|
|
|
250
197
|
async function handleVerifyToken() {
|
|
251
198
|
if (!adapter?.verifyToken || !apiKey) return;
|
|
252
|
-
|
|
253
199
|
try {
|
|
254
200
|
verifyingToken = true;
|
|
255
201
|
delete formErrors.apiKey;
|
|
256
202
|
const result = await adapter.verifyToken({ apiKey });
|
|
257
203
|
tokenVerification = result;
|
|
258
|
-
|
|
259
204
|
if (!result.valid) {
|
|
260
205
|
formErrors.apiKey = result.error || 'Token verification failed';
|
|
261
206
|
}
|
|
@@ -268,12 +213,8 @@
|
|
|
268
213
|
}
|
|
269
214
|
}
|
|
270
215
|
|
|
271
|
-
|
|
272
|
-
if (mode === 'create') return 'Create New User';
|
|
273
|
-
return `Edit ${getUserDisplayName(user ?? null)}`;
|
|
274
|
-
}
|
|
216
|
+
const modalTitle = $derived(mode === 'create' ? 'Create user' : 'Edit user');
|
|
275
217
|
|
|
276
|
-
// Get API key from user's private_metadata
|
|
277
218
|
const apiKey = $derived(
|
|
278
219
|
user?.private_metadata &&
|
|
279
220
|
typeof user.private_metadata === 'object' &&
|
|
@@ -282,382 +223,206 @@
|
|
|
282
223
|
: ''
|
|
283
224
|
);
|
|
284
225
|
|
|
285
|
-
|
|
286
|
-
|
|
226
|
+
const canRegenerate = $derived(!!adapter?.generateApiKey && !!formData.permissions?.length);
|
|
227
|
+
|
|
228
|
+
const currentRoleLabel = $derived(
|
|
229
|
+
mode === 'edit' && initialRole
|
|
230
|
+
? (roles.find((r) => r.value === initialRole)?.label ?? initialRole)
|
|
231
|
+
: undefined
|
|
232
|
+
);
|
|
287
233
|
</script>
|
|
288
234
|
|
|
235
|
+
{#snippet sectionLabel(text: string, required: boolean = false)}
|
|
236
|
+
<span class="text-default-700 mb-2 block text-sm font-medium">
|
|
237
|
+
{text}
|
|
238
|
+
{#if required}<span class="text-danger-500">*</span>{/if}
|
|
239
|
+
</span>
|
|
240
|
+
{/snippet}
|
|
241
|
+
|
|
289
242
|
<Modal
|
|
290
243
|
{open}
|
|
291
244
|
onclose={handleClose}
|
|
292
|
-
title={
|
|
293
|
-
footerAlign="
|
|
294
|
-
contentClass="max-w-
|
|
245
|
+
title={modalTitle}
|
|
246
|
+
footerAlign="end"
|
|
247
|
+
contentClass="max-w-3xl"
|
|
295
248
|
class={cn(className)}
|
|
296
249
|
>
|
|
297
|
-
<form
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
250
|
+
<form
|
|
251
|
+
bind:this={formElement}
|
|
252
|
+
onsubmit={handleSubmit}
|
|
253
|
+
class="flex flex-col gap-6"
|
|
254
|
+
data-testid="user-form"
|
|
255
|
+
>
|
|
256
|
+
{#if mode === 'edit' && user}
|
|
257
|
+
<UserIdentityCard {user} roleLabel={currentRoleLabel} />
|
|
258
|
+
{/if}
|
|
303
259
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
id="first-name"
|
|
311
|
-
type="text"
|
|
312
|
-
bind:value={formData.first_name}
|
|
313
|
-
class="w-full rounded-lg border px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 {formErrors.first_name
|
|
314
|
-
? 'border-danger-300'
|
|
315
|
-
: 'border-default-300'}"
|
|
260
|
+
<!-- Profile -->
|
|
261
|
+
<div class="space-y-4">
|
|
262
|
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
263
|
+
<Input
|
|
264
|
+
name="first_name"
|
|
265
|
+
label="First name"
|
|
316
266
|
placeholder="First name"
|
|
317
|
-
|
|
267
|
+
bind:value={formData.first_name}
|
|
268
|
+
errors={formErrors.first_name ? [formErrors.first_name] : []}
|
|
318
269
|
required
|
|
270
|
+
testId="first-name-input"
|
|
319
271
|
/>
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
</div>
|
|
324
|
-
|
|
325
|
-
<!-- Last Name -->
|
|
326
|
-
<div>
|
|
327
|
-
<label for="last-name" class="text-default-700 mb-1 block text-sm font-medium">
|
|
328
|
-
Last Name <span class="text-danger-500">*</span>
|
|
329
|
-
</label>
|
|
330
|
-
<input
|
|
331
|
-
id="last-name"
|
|
332
|
-
type="text"
|
|
333
|
-
bind:value={formData.last_name}
|
|
334
|
-
class="w-full rounded-lg border px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 {formErrors.last_name
|
|
335
|
-
? 'border-danger-300'
|
|
336
|
-
: 'border-default-300'}"
|
|
272
|
+
<Input
|
|
273
|
+
name="last_name"
|
|
274
|
+
label="Last name"
|
|
337
275
|
placeholder="Last name"
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
/>
|
|
341
|
-
{#if formErrors.last_name}
|
|
342
|
-
<p class="text-danger-500 mt-1 text-xs">{formErrors.last_name}</p>
|
|
343
|
-
{/if}
|
|
344
|
-
</div>
|
|
345
|
-
|
|
346
|
-
<!-- Email Address -->
|
|
347
|
-
<div>
|
|
348
|
-
<label for="email" class="text-default-700 mb-1 block text-sm font-medium">
|
|
349
|
-
Email Address <span class="text-danger-500">*</span>
|
|
350
|
-
</label>
|
|
351
|
-
<input
|
|
352
|
-
id="email"
|
|
353
|
-
type="email"
|
|
354
|
-
bind:value={formData.email_addresses![0].email_address}
|
|
355
|
-
class="w-full rounded-lg border px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 {formErrors.email
|
|
356
|
-
? 'border-danger-300'
|
|
357
|
-
: 'border-default-300'}"
|
|
358
|
-
placeholder="user@example.com"
|
|
359
|
-
data-testid="email-input"
|
|
276
|
+
bind:value={formData.last_name}
|
|
277
|
+
errors={formErrors.last_name ? [formErrors.last_name] : []}
|
|
360
278
|
required
|
|
279
|
+
testId="last-name-input"
|
|
361
280
|
/>
|
|
362
|
-
{#if formErrors.email}
|
|
363
|
-
<p class="text-danger-500 mt-1 text-xs">{formErrors.email}</p>
|
|
364
|
-
{/if}
|
|
365
281
|
</div>
|
|
366
282
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
type="button"
|
|
378
|
-
onclick={handleVerifyToken}
|
|
379
|
-
disabled={verifyingToken}
|
|
380
|
-
class="disabled:text-default-400 inline-flex items-center gap-1 text-sm text-green-600 transition-colors hover:text-green-700 hover:underline disabled:cursor-not-allowed"
|
|
381
|
-
aria-label="Verify token"
|
|
382
|
-
data-testid="verify-token-button"
|
|
383
|
-
>
|
|
384
|
-
<svg
|
|
385
|
-
class="h-4 w-4 {verifyingToken ? 'animate-spin' : ''}"
|
|
386
|
-
fill="none"
|
|
387
|
-
stroke="currentColor"
|
|
388
|
-
viewBox="0 0 24 24"
|
|
389
|
-
>
|
|
390
|
-
<path
|
|
391
|
-
stroke-linecap="round"
|
|
392
|
-
stroke-linejoin="round"
|
|
393
|
-
stroke-width="2"
|
|
394
|
-
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
395
|
-
></path>
|
|
396
|
-
</svg>
|
|
397
|
-
Verify
|
|
398
|
-
</button>
|
|
399
|
-
{/if}
|
|
400
|
-
{#if adapter?.generateApiKey}
|
|
401
|
-
<button
|
|
402
|
-
type="button"
|
|
403
|
-
onclick={handleRegenerateApiKey}
|
|
404
|
-
disabled={regeneratingApiKey ||
|
|
405
|
-
!formData.permissions ||
|
|
406
|
-
formData.permissions.length === 0}
|
|
407
|
-
class="disabled:text-default-400 inline-flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-700 hover:underline disabled:cursor-not-allowed"
|
|
408
|
-
aria-label="Regenerate API key"
|
|
409
|
-
data-testid="regenerate-api-key-button"
|
|
410
|
-
>
|
|
411
|
-
<svg
|
|
412
|
-
class="h-4 w-4 {regeneratingApiKey ? 'animate-spin' : ''}"
|
|
413
|
-
fill="none"
|
|
414
|
-
stroke="currentColor"
|
|
415
|
-
viewBox="0 0 24 24"
|
|
416
|
-
>
|
|
417
|
-
<path
|
|
418
|
-
stroke-linecap="round"
|
|
419
|
-
stroke-linejoin="round"
|
|
420
|
-
stroke-width="2"
|
|
421
|
-
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
422
|
-
></path>
|
|
423
|
-
</svg>
|
|
424
|
-
Regenerate
|
|
425
|
-
</button>
|
|
426
|
-
{/if}
|
|
427
|
-
</div>
|
|
428
|
-
</div>
|
|
429
|
-
<div class="relative flex-1">
|
|
430
|
-
<input
|
|
431
|
-
id="api-key"
|
|
432
|
-
type={showApiKey ? 'text' : 'password'}
|
|
433
|
-
value={showApiKey ? apiKey : maskedApiKey}
|
|
434
|
-
readonly
|
|
435
|
-
class="border-default-300 bg-default-50 w-full rounded-lg border px-3 py-2 pr-10 font-mono text-sm"
|
|
436
|
-
placeholder="No API key generated"
|
|
437
|
-
data-testid="api-key-input"
|
|
438
|
-
/>
|
|
439
|
-
<button
|
|
440
|
-
type="button"
|
|
441
|
-
onclick={() => (showApiKey = !showApiKey)}
|
|
442
|
-
class="text-default-500 hover:text-default-700 absolute top-1/2 right-2 -translate-y-1/2"
|
|
443
|
-
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
|
444
|
-
data-testid="toggle-api-key-visibility"
|
|
445
|
-
>
|
|
446
|
-
{#if showApiKey}
|
|
447
|
-
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
448
|
-
<path
|
|
449
|
-
stroke-linecap="round"
|
|
450
|
-
stroke-linejoin="round"
|
|
451
|
-
stroke-width="2"
|
|
452
|
-
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.29 3.29m0 0A9.966 9.966 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
|
453
|
-
></path>
|
|
454
|
-
</svg>
|
|
455
|
-
{:else}
|
|
456
|
-
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
457
|
-
<path
|
|
458
|
-
stroke-linecap="round"
|
|
459
|
-
stroke-linejoin="round"
|
|
460
|
-
stroke-width="2"
|
|
461
|
-
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
462
|
-
></path>
|
|
463
|
-
<path
|
|
464
|
-
stroke-linecap="round"
|
|
465
|
-
stroke-linejoin="round"
|
|
466
|
-
stroke-width="2"
|
|
467
|
-
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
468
|
-
></path>
|
|
469
|
-
</svg>
|
|
470
|
-
{/if}
|
|
471
|
-
</button>
|
|
472
|
-
</div>
|
|
473
|
-
{#if formErrors.apiKey}
|
|
474
|
-
<p class="text-danger-500 mt-1 text-xs">{formErrors.apiKey}</p>
|
|
475
|
-
{:else if tokenVerification}
|
|
476
|
-
{#if tokenVerification.valid}
|
|
477
|
-
<div class="bg-success-50 border-success-200 mt-2 rounded-lg border p-3">
|
|
478
|
-
<div class="flex items-start gap-2">
|
|
479
|
-
<svg
|
|
480
|
-
class="text-success-600 mt-0.5 h-4 w-4 shrink-0"
|
|
481
|
-
fill="none"
|
|
482
|
-
stroke="currentColor"
|
|
483
|
-
viewBox="0 0 24 24"
|
|
484
|
-
>
|
|
485
|
-
<path
|
|
486
|
-
stroke-linecap="round"
|
|
487
|
-
stroke-linejoin="round"
|
|
488
|
-
stroke-width="2"
|
|
489
|
-
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
490
|
-
></path>
|
|
491
|
-
</svg>
|
|
492
|
-
<div class="min-w-0 flex-1">
|
|
493
|
-
<p class="text-success-800 text-xs font-medium">Token verified successfully</p>
|
|
494
|
-
{#if tokenVerification.sub}
|
|
495
|
-
<p class="text-success-700 mt-1 text-xs">
|
|
496
|
-
Sub: {tokenVerification.sub}
|
|
497
|
-
</p>
|
|
498
|
-
{/if}
|
|
499
|
-
{#if tokenVerification.client_id}
|
|
500
|
-
<p class="text-success-700 mt-1 text-xs">
|
|
501
|
-
Client ID: {tokenVerification.client_id}
|
|
502
|
-
</p>
|
|
503
|
-
{/if}
|
|
504
|
-
{#if tokenVerification.scopes && tokenVerification.scopes.length > 0}
|
|
505
|
-
<p class="text-success-700 mt-1 text-xs">
|
|
506
|
-
Scopes: {tokenVerification.scopes.join(', ')}
|
|
507
|
-
</p>
|
|
508
|
-
{/if}
|
|
509
|
-
</div>
|
|
510
|
-
</div>
|
|
511
|
-
</div>
|
|
512
|
-
{:else}
|
|
513
|
-
<div class="bg-danger-50 border-danger-200 mt-2 rounded-lg border p-3">
|
|
514
|
-
<div class="flex items-start gap-2">
|
|
515
|
-
<svg
|
|
516
|
-
class="text-danger-600 mt-0.5 h-4 w-4 shrink-0"
|
|
517
|
-
fill="none"
|
|
518
|
-
stroke="currentColor"
|
|
519
|
-
viewBox="0 0 24 24"
|
|
520
|
-
>
|
|
521
|
-
<path
|
|
522
|
-
stroke-linecap="round"
|
|
523
|
-
stroke-linejoin="round"
|
|
524
|
-
stroke-width="2"
|
|
525
|
-
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
526
|
-
></path>
|
|
527
|
-
</svg>
|
|
528
|
-
<div class="min-w-0 flex-1">
|
|
529
|
-
<p class="text-danger-800 text-xs font-medium">Token verification failed</p>
|
|
530
|
-
{#if tokenVerification.error}
|
|
531
|
-
<p class="text-danger-700 mt-1 text-xs">{tokenVerification.error}</p>
|
|
532
|
-
{/if}
|
|
533
|
-
</div>
|
|
534
|
-
</div>
|
|
535
|
-
</div>
|
|
536
|
-
{/if}
|
|
537
|
-
{:else}
|
|
538
|
-
<p class="text-default-500 mt-1 text-xs">
|
|
539
|
-
API keys are system-managed and cannot be manually edited
|
|
540
|
-
</p>
|
|
541
|
-
{/if}
|
|
542
|
-
</div>
|
|
543
|
-
{/if}
|
|
283
|
+
<Input
|
|
284
|
+
name="email"
|
|
285
|
+
type="email"
|
|
286
|
+
label="Email address"
|
|
287
|
+
placeholder="user@example.com"
|
|
288
|
+
bind:value={formData.email_addresses![0].email_address}
|
|
289
|
+
errors={formErrors.email ? [formErrors.email] : []}
|
|
290
|
+
required
|
|
291
|
+
testId="email-input"
|
|
292
|
+
/>
|
|
544
293
|
</div>
|
|
545
294
|
|
|
546
|
-
<!--
|
|
547
|
-
|
|
548
|
-
<div class="
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
{@const isPreselected = initialRole === role.value && isSelected}
|
|
563
|
-
<button
|
|
564
|
-
type="button"
|
|
565
|
-
onclick={() => handleRoleChange(role.value)}
|
|
566
|
-
class="cursor-pointer rounded-lg border-2 p-2 text-left transition-all {isSelected
|
|
567
|
-
? isAdministrator && isPreselected
|
|
568
|
-
? 'border-blue-500 bg-blue-50 opacity-75'
|
|
569
|
-
: 'border-blue-500 bg-blue-50'
|
|
570
|
-
: 'border-default-200 hover:border-default-300 bg-white'}"
|
|
571
|
-
data-testid="role-{role.value}"
|
|
572
|
-
>
|
|
573
|
-
<div class="flex items-center justify-between gap-2">
|
|
574
|
-
<div class="min-w-0 flex-1">
|
|
575
|
-
<h4 class="text-default-900 text-sm font-semibold">{role.label}</h4>
|
|
576
|
-
{#if role.description}
|
|
577
|
-
<p class="text-default-600 mt-1 line-clamp-2 text-xs">{role.description}</p>
|
|
578
|
-
{/if}
|
|
579
|
-
</div>
|
|
580
|
-
<div
|
|
581
|
-
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 {isSelected
|
|
582
|
-
? 'border-blue-500 bg-blue-500'
|
|
583
|
-
: 'border-default-300 bg-white'}"
|
|
584
|
-
>
|
|
585
|
-
{#if isSelected}
|
|
586
|
-
<div class="h-2 w-2 rounded-full bg-white"></div>
|
|
587
|
-
{/if}
|
|
588
|
-
</div>
|
|
589
|
-
</div>
|
|
590
|
-
</button>
|
|
591
|
-
{/each}
|
|
592
|
-
</div>
|
|
295
|
+
<!-- Role + permissions -->
|
|
296
|
+
{#if roles && roles.length > 0}
|
|
297
|
+
<div class="space-y-3">
|
|
298
|
+
{@render sectionLabel('Role', mode === 'create')}
|
|
299
|
+
<div class="grid grid-cols-1 gap-2">
|
|
300
|
+
{#each roles as role, index (`${role.value}-${index}`)}
|
|
301
|
+
{@const isSelected = formData.role === role.value}
|
|
302
|
+
{@const isAdmin = role.value.toLowerCase() === 'admin'}
|
|
303
|
+
{@const isPreselected = initialRole === role.value && isSelected}
|
|
304
|
+
<RoleCard
|
|
305
|
+
{role}
|
|
306
|
+
selected={isSelected}
|
|
307
|
+
dimmed={isAdmin && isPreselected}
|
|
308
|
+
onclick={() => handleRoleChange(role)}
|
|
309
|
+
/>
|
|
310
|
+
{/each}
|
|
593
311
|
</div>
|
|
594
|
-
|
|
312
|
+
{#if formErrors.role}
|
|
313
|
+
<p class="text-danger-500 mt-1 text-xs">{formErrors.role}</p>
|
|
314
|
+
{/if}
|
|
595
315
|
|
|
596
|
-
|
|
597
|
-
{#if loadingPermissions && mode === 'edit'}
|
|
598
|
-
<div>
|
|
599
|
-
<div class="bg-default-200 mb-2 h-4 w-32 animate-pulse rounded"></div>
|
|
316
|
+
{#if loadingPermissions && mode === 'edit'}
|
|
600
317
|
<div class="bg-default-50 rounded-lg p-3">
|
|
601
|
-
<div class="
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
></div>
|
|
609
|
-
</div>
|
|
318
|
+
<div class="bg-default-200 mb-2 h-3 w-24 animate-pulse rounded"></div>
|
|
319
|
+
<div class="flex flex-wrap gap-1.5">
|
|
320
|
+
{#each [0, 1, 2, 3] as i (i)}
|
|
321
|
+
<div
|
|
322
|
+
class="bg-default-200 h-5 animate-pulse rounded-md"
|
|
323
|
+
style="width: {60 + i * 15}px"
|
|
324
|
+
></div>
|
|
610
325
|
{/each}
|
|
611
326
|
</div>
|
|
612
327
|
</div>
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
<div class="bg-default-50 max-h-60 overflow-y-auto rounded-lg p-3">
|
|
620
|
-
<div class="space-y-2">
|
|
328
|
+
{:else if formData.permissions && formData.permissions.length > 0}
|
|
329
|
+
<div>
|
|
330
|
+
<span class="text-default-500 mb-1.5 block text-xs font-medium">
|
|
331
|
+
Granted permissions ({formData.permissions.length})
|
|
332
|
+
</span>
|
|
333
|
+
<div class="flex flex-wrap gap-1.5">
|
|
621
334
|
{#each formData.permissions as permission, index (`${permission}-${index}`)}
|
|
622
|
-
<
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
335
|
+
<span
|
|
336
|
+
class="bg-default-100 text-default-700 rounded-md px-2 py-0.5 font-mono text-xs"
|
|
337
|
+
>
|
|
338
|
+
{permission}
|
|
339
|
+
</span>
|
|
626
340
|
{/each}
|
|
627
341
|
</div>
|
|
628
342
|
</div>
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
343
|
+
{/if}
|
|
344
|
+
</div>
|
|
345
|
+
{/if}
|
|
346
|
+
|
|
347
|
+
<!-- Credentials (edit only) -->
|
|
348
|
+
{#if mode === 'edit' && (apiKey || adapter?.generateApiKey)}
|
|
349
|
+
<div class="space-y-2">
|
|
350
|
+
<ApiKeyField
|
|
351
|
+
value={apiKey}
|
|
352
|
+
label="Mako API Key"
|
|
353
|
+
error={formErrors.apiKey}
|
|
354
|
+
helperText={formErrors.apiKey
|
|
355
|
+
? undefined
|
|
356
|
+
: 'API keys are system-managed and cannot be manually edited'}
|
|
357
|
+
verify={adapter?.verifyToken && apiKey
|
|
358
|
+
? {
|
|
359
|
+
label: 'Verify',
|
|
360
|
+
loading: verifyingToken,
|
|
361
|
+
onclick: handleVerifyToken,
|
|
362
|
+
testId: 'verify-token-button'
|
|
363
|
+
}
|
|
364
|
+
: undefined}
|
|
365
|
+
regenerate={adapter?.generateApiKey
|
|
366
|
+
? {
|
|
367
|
+
label: apiKey ? 'Regenerate' : 'Generate',
|
|
368
|
+
loading: regeneratingApiKey,
|
|
369
|
+
disabled: !canRegenerate,
|
|
370
|
+
onclick: handleRegenerateApiKey,
|
|
371
|
+
testId: 'regenerate-api-key-button'
|
|
372
|
+
}
|
|
373
|
+
: undefined}
|
|
374
|
+
/>
|
|
375
|
+
|
|
376
|
+
{#if tokenVerification && !formErrors.apiKey}
|
|
377
|
+
{#if tokenVerification.valid}
|
|
378
|
+
<div class="bg-success-50 border-success-200 rounded-lg border p-3">
|
|
379
|
+
<p class="text-success-800 text-xs font-medium">Token verified successfully</p>
|
|
380
|
+
{#if tokenVerification.sub}
|
|
381
|
+
<p class="text-success-700 mt-1 text-xs">Sub: {tokenVerification.sub}</p>
|
|
382
|
+
{/if}
|
|
383
|
+
{#if tokenVerification.client_id}
|
|
384
|
+
<p class="text-success-700 mt-1 text-xs">
|
|
385
|
+
Client ID: {tokenVerification.client_id}
|
|
386
|
+
</p>
|
|
387
|
+
{/if}
|
|
388
|
+
{#if tokenVerification.scopes?.length}
|
|
389
|
+
<p class="text-success-700 mt-1 text-xs">
|
|
390
|
+
Scopes: {tokenVerification.scopes.join(', ')}
|
|
391
|
+
</p>
|
|
392
|
+
{/if}
|
|
393
|
+
</div>
|
|
394
|
+
{:else}
|
|
395
|
+
<div class="bg-danger-50 border-danger-200 rounded-lg border p-3">
|
|
396
|
+
<p class="text-danger-800 text-xs font-medium">Token verification failed</p>
|
|
397
|
+
{#if tokenVerification.error}
|
|
398
|
+
<p class="text-danger-700 mt-1 text-xs">{tokenVerification.error}</p>
|
|
399
|
+
{/if}
|
|
400
|
+
</div>
|
|
401
|
+
{/if}
|
|
402
|
+
{/if}
|
|
403
|
+
</div>
|
|
404
|
+
{/if}
|
|
632
405
|
</form>
|
|
633
406
|
|
|
634
|
-
<!-- Form Actions: error on left, buttons on right via footerAlign="between" -->
|
|
635
407
|
{#snippet footer()}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
disabled={saving}
|
|
656
|
-
loading={saving}
|
|
657
|
-
data-testid="save-user-button"
|
|
658
|
-
>
|
|
659
|
-
{mode === 'create' ? 'Create User' : 'Save Changes'}
|
|
660
|
-
</Button>
|
|
661
|
-
</div>
|
|
408
|
+
<Button
|
|
409
|
+
variant="outline"
|
|
410
|
+
onclick={handleClose}
|
|
411
|
+
disabled={saving}
|
|
412
|
+
type="button"
|
|
413
|
+
testId="cancel-button"
|
|
414
|
+
>
|
|
415
|
+
Cancel
|
|
416
|
+
</Button>
|
|
417
|
+
<Button
|
|
418
|
+
type="button"
|
|
419
|
+
color={Color.PRIMARY}
|
|
420
|
+
onclick={() => formElement?.requestSubmit()}
|
|
421
|
+
disabled={saving}
|
|
422
|
+
loading={saving}
|
|
423
|
+
testId="save-user-button"
|
|
424
|
+
>
|
|
425
|
+
{mode === 'create' ? 'Create user' : 'Save changes'}
|
|
426
|
+
</Button>
|
|
662
427
|
{/snippet}
|
|
663
428
|
</Modal>
|