@makolabs/ripple 3.0.10 → 3.0.11

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.
@@ -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 || roles.length === 0 || !userPermissions || userPermissions.length === 0) {
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 (role.permissions && role.permissions.length > 0) {
64
- const rolePermissions = role.permissions;
65
- // Check if all role permissions are present in user permissions
66
- const allPermissionsMatch = rolePermissions.every((perm: string) =>
67
- userPermissions.includes(perm)
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.length > 0) {
80
- const allRolePermissionsInUser = role.permissions.every((perm: string) =>
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 && role.permissions.length > 0) {
93
- const hasMatchingPermission = role.permissions.some((perm: string) =>
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 && user.permissions.length > 0) {
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
- // Basic validation
153
- if (!formData.first_name?.trim()) {
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
- if (Object.keys(formErrors).length > 0) {
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
- formErrors.submit = error instanceof Error ? error.message : 'Failed to save user';
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(roleValue: string) {
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: roleValue,
199
- permissions: role ? [...role.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; // Clear previous verification
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 // 8 seconds for important warnings
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
- function getModalTitle() {
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
- // Mask API key for display
286
- const maskedApiKey = $derived(apiKey ? '•'.repeat(Math.min(apiKey.length, 40)) : '');
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={getModalTitle()}
293
- footerAlign="between"
294
- contentClass="max-w-4xl"
245
+ title={modalTitle}
246
+ footerAlign="end"
247
+ contentClass="max-w-3xl"
295
248
  class={cn(className)}
296
249
  >
297
- <form bind:this={formElement} onsubmit={handleSubmit} class="flex gap-6" data-testid="user-form">
298
- <!-- Left Column: Profile Information -->
299
- <div class="min-w-0 flex-1 space-y-4">
300
- <div class="border-default-200 border-b pb-3">
301
- <h3 class="text-default-900 text-lg font-semibold">Profile Information</h3>
302
- </div>
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
- <!-- First Name -->
305
- <div>
306
- <label for="first-name" class="text-default-700 mb-1 block text-sm font-medium">
307
- First Name <span class="text-danger-500">*</span>
308
- </label>
309
- <input
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
- data-testid="first-name-input"
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
- {#if formErrors.first_name}
321
- <p class="text-danger-500 mt-1 text-xs">{formErrors.first_name}</p>
322
- {/if}
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
- data-testid="last-name-input"
339
- required
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
- <!-- Mako API Key (Edit mode only) -->
368
- {#if mode === 'edit' && (apiKey || adapter?.generateApiKey)}
369
- <div>
370
- <div class="mb-2 flex items-center justify-between">
371
- <label for="api-key" class="text-default-700 block text-sm font-medium">
372
- Mako API Key
373
- </label>
374
- <div class="flex items-center gap-3">
375
- {#if adapter?.verifyToken && apiKey}
376
- <button
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
- <!-- Right Column: Permissions & Role -->
547
- <div class="min-w-0 flex-1 space-y-4">
548
- <div class="border-default-200 border-b pb-3">
549
- <h3 class="text-default-900 text-lg font-semibold">Role & Permissions</h3>
550
- </div>
551
-
552
- <!-- Role Selection -->
553
- {#if roles && roles.length > 0}
554
- <div>
555
- <span class="text-default-700 mb-3 block text-sm font-medium">
556
- Select User Role {#if mode === 'create'}<span class="text-danger-500">*</span>{/if}
557
- </span>
558
- <div class="grid grid-cols-1 gap-2">
559
- {#each roles as role, index (`${role.value}-${index}`)}
560
- {@const isSelected = formData.role === role.value}
561
- {@const isAdministrator = role.value.toLowerCase() === 'admin'}
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
- {/if}
312
+ {#if formErrors.role}
313
+ <p class="text-danger-500 mt-1 text-xs">{formErrors.role}</p>
314
+ {/if}
595
315
 
596
- <!-- Permission Preview -->
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="space-y-3">
602
- {#each [0, 1, 2] as i (i)}
603
- <div class="flex items-center gap-2">
604
- <div class="bg-default-200 h-1.5 w-1.5 shrink-0 animate-pulse rounded-full"></div>
605
- <div
606
- class="bg-default-200 h-3.5 animate-pulse rounded"
607
- style="width: {50 + i * 20}%"
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
- </div>
614
- {:else if formData.permissions && formData.permissions.length > 0}
615
- <div>
616
- <h4 class="text-default-700 mb-2 text-sm font-medium">
617
- Permissions ({formData.permissions.length})
618
- </h4>
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
- <div class="flex items-start gap-2 text-xs">
623
- <div class="mt-1 h-1 w-1 shrink-0 rounded-full bg-blue-500"></div>
624
- <div class="text-default-700 font-mono">{permission}</div>
625
- </div>
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
- </div>
630
- {/if}
631
- </div>
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
- {#if formErrors.submit}
637
- <p class="text-danger-500 text-sm">{formErrors.submit}</p>
638
- {:else}
639
- <div></div>
640
- {/if}
641
- <div class="flex gap-3">
642
- <Button
643
- variant="outline"
644
- onclick={handleClose}
645
- disabled={saving}
646
- type="button"
647
- data-testid="cancel-button"
648
- >
649
- Cancel
650
- </Button>
651
- <Button
652
- type="button"
653
- color="primary"
654
- onclick={() => formElement?.requestSubmit()}
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>