@makolabs/ripple 1.6.9 → 1.7.2

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.
@@ -176,6 +176,10 @@ export async function getUserPermissions(userId) {
176
176
  export async function updateUserPermissions(options) {
177
177
  await delay();
178
178
  const { userId, permissions } = options;
179
+ // Validate permissions - require at least one permission scope
180
+ if (permissions.length === 0) {
181
+ throw new Error('At least one permission scope is required');
182
+ }
179
183
  const user = mockUsers.find((u) => u.id === userId);
180
184
  if (!user) {
181
185
  throw new Error(`User with ID ${userId} not found`);
@@ -188,6 +192,10 @@ export async function updateUserPermissions(options) {
188
192
  */
189
193
  export async function generateApiKey(options) {
190
194
  await delay();
195
+ // Validate permissions - require at least one permission scope
196
+ if (options.permissions.length === 0) {
197
+ throw new Error('At least one permission scope is required');
198
+ }
191
199
  const { userId } = options;
192
200
  const user = mockUsers.find((u) => u.id === userId);
193
201
  if (!user) {
@@ -2,7 +2,6 @@ import { query, command } from '$app/server';
2
2
  import { getRequestEvent } from '$app/server';
3
3
  import { env } from '$env/dynamic/private';
4
4
  const CLIENT_ID = env.CLIENT_ID || 'sharkfin';
5
- const PERMISSION_PREFIX = env.PERMISSION_PREFIX || 'sharkfin:';
6
5
  const ORGANIZATION_ID = env.ALLOWED_ORG_ID;
7
6
  function handleClerkError(error, defaultMessage) {
8
7
  if (error && typeof error === 'object' && 'status' in error && 'details' in error) {
@@ -147,10 +146,7 @@ async function verifyApiKeyToken(apiKey) {
147
146
  }
148
147
  }
149
148
  async function createUserPermissions(userId, permissions, clientId = CLIENT_ID) {
150
- const filteredPermissions = PERMISSION_PREFIX
151
- ? permissions.filter((scope) => scope.startsWith(PERMISSION_PREFIX))
152
- : permissions;
153
- if (filteredPermissions.length === 0) {
149
+ if (permissions.length === 0) {
154
150
  return null;
155
151
  }
156
152
  return await makeAdminRequest('/admin/keys', {
@@ -158,7 +154,7 @@ async function createUserPermissions(userId, permissions, clientId = CLIENT_ID)
158
154
  body: JSON.stringify({
159
155
  client_id: clientId,
160
156
  sub: userId,
161
- scopes: filteredPermissions
157
+ scopes: permissions
162
158
  })
163
159
  });
164
160
  }
@@ -420,12 +416,13 @@ async function refreshTokenIfSelfUpdate(userId) {
420
416
  export const updateUserPermissions = command('unchecked', async (options) => {
421
417
  const { userId, permissions } = options;
422
418
  try {
419
+ // Validate permissions - require at least one permission scope
420
+ if (permissions.length === 0) {
421
+ throw new Error('At least one permission scope is required');
422
+ }
423
423
  // Fetch user's active keys
424
424
  const allKeysData = await makeAdminRequest(`/admin/keys?client_id=${CLIENT_ID}&sub=${userId}`);
425
425
  const userKeys = (allKeysData?.data?.data || []).filter((key) => key.status === 'active');
426
- const filteredPermissions = PERMISSION_PREFIX
427
- ? permissions.filter((scope) => scope.startsWith(PERMISSION_PREFIX))
428
- : permissions;
429
426
  if (userKeys.length === 0) {
430
427
  // No active key exists, create new one
431
428
  const newKeyResult = await createUserPermissions(userId, permissions);
@@ -452,7 +449,7 @@ export const updateUserPermissions = command('unchecked', async (options) => {
452
449
  await makeAdminRequest(`/admin/keys/${keyId}`, {
453
450
  method: 'PUT',
454
451
  body: JSON.stringify({
455
- scopes: filteredPermissions
452
+ scopes: permissions
456
453
  })
457
454
  });
458
455
  // Verify the token has updated scopes
@@ -462,9 +459,11 @@ export const updateUserPermissions = command('unchecked', async (options) => {
462
459
  console.log('[updateUserPermissions] Key verification:', verification);
463
460
  if (verification.valid) {
464
461
  // Check if the scopes match what we expect
465
- const scopesMatch = filteredPermissions.every((perm) => verification.scopes?.includes(perm));
462
+ // Note: permissions.length > 0 is guaranteed by validation above
463
+ const scopesMatch = permissions.length > 0 &&
464
+ permissions.every((perm) => verification.scopes?.includes(perm));
466
465
  if (!scopesMatch) {
467
- console.warn('[updateUserPermissions] Scopes mismatch. Expected:', filteredPermissions, 'Got:', verification.scopes);
466
+ console.warn('[updateUserPermissions] Scopes mismatch. Expected:', permissions, 'Got:', verification.scopes);
468
467
  }
469
468
  }
470
469
  else {
@@ -493,12 +492,9 @@ export const updateUserPermissions = command('unchecked', async (options) => {
493
492
  });
494
493
  export const generateApiKey = command('unchecked', async (options) => {
495
494
  try {
496
- let filteredPermissions = PERMISSION_PREFIX
497
- ? options.permissions.filter((scope) => scope.startsWith(PERMISSION_PREFIX))
498
- : options.permissions;
499
- // Default to readonly permission if none provided (first-time key generation)
500
- if (filteredPermissions.length === 0 && PERMISSION_PREFIX) {
501
- filteredPermissions = [`${PERMISSION_PREFIX}readonly`];
495
+ // No default permissions - require explicit permissions to be passed
496
+ if (options.permissions.length === 0) {
497
+ throw new Error('At least one permission scope is required');
502
498
  }
503
499
  // Check if user has existing active key
504
500
  const allKeysData = await makeAdminRequest(`/admin/keys?client_id=${CLIENT_ID}&sub=${options.userId}`);
@@ -507,6 +503,7 @@ export const generateApiKey = command('unchecked', async (options) => {
507
503
  let wasRotated = false;
508
504
  let oldApiKey;
509
505
  let currentUser = null;
506
+ let verificationWarning;
510
507
  if (userKeys.length > 0 && options.revokeOld) {
511
508
  // Use rotate endpoint (per Mako Auth API spec)
512
509
  const keyId = userKeys[0].id;
@@ -516,7 +513,7 @@ export const generateApiKey = command('unchecked', async (options) => {
516
513
  const rotateResult = await makeAdminRequest(`/admin/keys/${keyId}/rotate`, {
517
514
  method: 'POST',
518
515
  body: JSON.stringify({
519
- scopes: filteredPermissions
516
+ scopes: options.permissions
520
517
  })
521
518
  });
522
519
  // Rotate endpoint returns key in data.key field
@@ -544,22 +541,25 @@ export const generateApiKey = command('unchecked', async (options) => {
544
541
  console.log('[generateApiKey] New key verification:', newKeyVerification);
545
542
  if (newKeyVerification.valid) {
546
543
  // Check if the scopes match what we expect
547
- const scopesMatch = filteredPermissions.every((perm) => newKeyVerification.scopes?.includes(perm));
544
+ const scopesMatch = options.permissions.every((perm) => newKeyVerification.scopes?.includes(perm));
548
545
  if (!scopesMatch) {
549
- console.warn('[generateApiKey] Scopes mismatch. Expected:', filteredPermissions, 'Got:', newKeyVerification.scopes);
546
+ console.warn('[generateApiKey] Scopes mismatch. Expected:', options.permissions, 'Got:', newKeyVerification.scopes);
547
+ verificationWarning = `New API key scopes do not match expected permissions. Expected: ${options.permissions.join(', ')}, Got: ${newKeyVerification.scopes?.join(', ') || 'none'}`;
550
548
  }
551
549
  }
552
550
  else {
553
551
  console.warn('[generateApiKey] New key verification failed:', newKeyVerification.error);
552
+ verificationWarning = `New API key failed verification - ${newKeyVerification.error || 'Unknown error'}`;
554
553
  }
555
554
  }
556
555
  catch (verifyError) {
557
556
  console.warn('[generateApiKey] Could not verify new key:', verifyError);
557
+ verificationWarning = `Could not verify new API key - ${verifyError instanceof Error ? verifyError.message : 'Unknown error'}`;
558
558
  }
559
559
  }
560
560
  else {
561
561
  // Create new key if none exists or revokeOld is false
562
- const createData = await createUserPermissions(options.userId, filteredPermissions);
562
+ const createData = await createUserPermissions(options.userId, options.permissions);
563
563
  if (!createData) {
564
564
  throw new Error('Failed to create admin key');
565
565
  }
@@ -593,7 +593,8 @@ export const generateApiKey = command('unchecked', async (options) => {
593
593
  const result = {
594
594
  success: true,
595
595
  apiKey: newApiKey,
596
- message: wasRotated ? 'API key rotated successfully' : 'API key generated successfully'
596
+ message: wasRotated ? 'API key rotated successfully' : 'API key generated successfully',
597
+ verificationWarning
597
598
  };
598
599
  return JSON.parse(JSON.stringify(result));
599
600
  }
package/dist/index.d.ts CHANGED
@@ -339,6 +339,10 @@ export interface NavItemProps {
339
339
  export interface SidebarProps {
340
340
  items?: NavigationItem[];
341
341
  logo: LogoType;
342
+ /** Optional footer snippet rendered at the bottom of the sidebar */
343
+ footer?: Snippet<[{
344
+ collapsed: boolean;
345
+ }]>;
342
346
  }
343
347
  export { tv, cn } from './helper/cls.js';
344
348
  export { isRouteActive } from './helper/nav.svelte.js';
@@ -1030,6 +1034,7 @@ export interface UserManagementAdapter {
1030
1034
  success: boolean;
1031
1035
  apiKey: string;
1032
1036
  message: string;
1037
+ verificationWarning?: string;
1033
1038
  }>;
1034
1039
  verifyToken?: (options: {
1035
1040
  apiKey: string;
@@ -5,7 +5,7 @@
5
5
  import { isRouteActive } from '../../helper/nav.svelte.js';
6
6
  import { resolve } from '$app/paths';
7
7
 
8
- let { items = [], logo }: SidebarProps = $props();
8
+ let { items = [], logo, footer }: SidebarProps = $props();
9
9
  let menubar: MenuBar = $state({
10
10
  collapsed: false
11
11
  });
@@ -178,6 +178,13 @@
178
178
  {/each}
179
179
  </nav>
180
180
  </div>
181
+
182
+ <!-- Footer slot for custom content -->
183
+ {#if footer}
184
+ <div class="shrink-0 border-t border-white/10 px-3 py-3">
185
+ {@render footer({ collapsed: menubar.collapsed })}
186
+ </div>
187
+ {/if}
181
188
  </div>
182
189
 
183
190
  {#snippet ToggleIcon(classes = 'size-6 shrink-0 text-default-200')}
@@ -4,9 +4,8 @@
4
4
  import UserTable from './UserTable.svelte';
5
5
  import UserModal from './UserModal.svelte';
6
6
  import UserViewModal from './UserViewModal.svelte';
7
- import type { User, UserManagementProps, Role, Permission, GetUsersResult } from '../index.js';
7
+ import type { User, UserManagementProps, Role, Permission } from '../index.js';
8
8
  import { SvelteSet } from 'svelte/reactivity';
9
- import type { RemoteQuery } from '@sveltejs/kit';
10
9
 
11
10
  let {
12
11
  adapter,
@@ -60,13 +59,7 @@
60
59
 
61
60
  // Refresh the query cache to get fresh data
62
61
  async function refreshUsersQuery() {
63
- const query = adapter.getUsers({
64
- page: currentPage,
65
- pageSize,
66
- sortBy: sortBy || undefined,
67
- sortOrder: sortOrder || 'desc'
68
- }) as RemoteQuery<GetUsersResult>;
69
- await query.refresh();
62
+ await loadUsers();
70
63
  }
71
64
 
72
65
  // Handlers
@@ -146,10 +139,8 @@
146
139
  } else {
147
140
  await adapter.updateUser({ userId: user.id, userData: user });
148
141
  }
149
- // Invalidate the query cache by calling refresh() before loading
142
+ // Refresh the users list
150
143
  await refreshUsersQuery();
151
- // Force refresh by reassigning state to trigger reactivity
152
- await loadUsers();
153
144
  } catch (error) {
154
145
  console.error('Error saving user:', error);
155
146
  // Reload on error to restore correct state
@@ -180,11 +171,8 @@
180
171
  // Perform the actual delete operation
181
172
  await adapter.deleteUser(userId);
182
173
 
183
- // Invalidate the query cache by calling refresh() before loading
184
- await refreshUsersQuery();
185
-
186
174
  // Refresh to ensure we have the latest data
187
- await loadUsers();
175
+ await refreshUsersQuery();
188
176
  } catch (error) {
189
177
  console.error('Error deleting user:', error);
190
178
  // Reload on error to restore correct state
@@ -225,11 +213,8 @@
225
213
  // Perform the actual delete operation
226
214
  await adapter.deleteUsers(userIds);
227
215
 
228
- // Invalidate the query cache by calling refresh() before loading
229
- await refreshUsersQuery();
230
-
231
216
  // Refresh to ensure we have the latest data
232
- await loadUsers();
217
+ await refreshUsersQuery();
233
218
  } catch (error) {
234
219
  console.error('Error deleting users:', error);
235
220
  // Reload on error to restore correct state
@@ -9,6 +9,7 @@
9
9
  type Role,
10
10
  getUserDisplayName
11
11
  } from '../index.js';
12
+ import { toast } from 'svelte-sonner';
12
13
 
13
14
  // Icons as simple SVGs
14
15
  let {
@@ -222,6 +223,14 @@
222
223
  }
223
224
  };
224
225
  }
226
+
227
+ // Show warning toast if new key verification failed
228
+ if (result.verificationWarning) {
229
+ toast.warning('API Key Verification Warning', {
230
+ description: result.verificationWarning,
231
+ duration: 8000 // 8 seconds for important warnings
232
+ });
233
+ }
225
234
  } catch (error) {
226
235
  console.error('Error regenerating API key:', error);
227
236
  formErrors.apiKey = error instanceof Error ? error.message : 'Failed to regenerate API key';
@@ -276,7 +285,7 @@
276
285
  contentclass="max-w-4xl"
277
286
  class={cn(className)}
278
287
  >
279
- <form bind:this={formElement} onsubmit={handleSubmit} class="flex gap-6">
288
+ <form bind:this={formElement} onsubmit={handleSubmit} class="flex gap-6" data-testid="user-form">
280
289
  <!-- Left Column: Profile Information -->
281
290
  <div class="min-w-0 flex-1 space-y-4">
282
291
  <div class="border-default-200 border-b pb-3">
@@ -296,6 +305,7 @@
296
305
  ? 'border-danger-300'
297
306
  : 'border-default-300'}"
298
307
  placeholder="First name"
308
+ data-testid="first-name-input"
299
309
  required
300
310
  />
301
311
  {#if formErrors.first_name}
@@ -316,6 +326,7 @@
316
326
  ? 'border-danger-300'
317
327
  : 'border-default-300'}"
318
328
  placeholder="Last name"
329
+ data-testid="last-name-input"
319
330
  required
320
331
  />
321
332
  {#if formErrors.last_name}
@@ -336,6 +347,7 @@
336
347
  ? 'border-danger-300'
337
348
  : 'border-default-300'}"
338
349
  placeholder="user@example.com"
350
+ data-testid="email-input"
339
351
  required
340
352
  />
341
353
  {#if formErrors.email}
@@ -358,6 +370,7 @@
358
370
  disabled={verifyingToken}
359
371
  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"
360
372
  aria-label="Verify token"
373
+ data-testid="verify-token-button"
361
374
  >
362
375
  <svg
363
376
  class="h-4 w-4 {verifyingToken ? 'animate-spin' : ''}"
@@ -384,6 +397,7 @@
384
397
  formData.permissions.length === 0}
385
398
  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"
386
399
  aria-label="Regenerate API key"
400
+ data-testid="regenerate-api-key-button"
387
401
  >
388
402
  <svg
389
403
  class="h-4 w-4 {regeneratingApiKey ? 'animate-spin' : ''}"
@@ -411,12 +425,14 @@
411
425
  readonly
412
426
  class="border-default-300 bg-default-50 w-full rounded-lg border px-3 py-2 pr-10 font-mono text-sm"
413
427
  placeholder="No API key generated"
428
+ data-testid="api-key-input"
414
429
  />
415
430
  <button
416
431
  type="button"
417
432
  onclick={() => (showApiKey = !showApiKey)}
418
433
  class="text-default-500 hover:text-default-700 absolute top-1/2 right-2 -translate-y-1/2"
419
434
  aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
435
+ data-testid="toggle-api-key-visibility"
420
436
  >
421
437
  {#if showApiKey}
422
438
  <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -533,6 +549,7 @@
533
549
  ? 'border-blue-500 bg-blue-50 opacity-75'
534
550
  : 'border-blue-500 bg-blue-50'
535
551
  : 'border-default-200 hover:border-default-300 bg-white'}"
552
+ data-testid="role-{role.value}"
536
553
  >
537
554
  <div class="flex items-center justify-between gap-2">
538
555
  <div class="min-w-0 flex-1">
@@ -587,7 +604,13 @@
587
604
  <div></div>
588
605
  {/if}
589
606
  <div class="flex gap-3">
590
- <Button variant="outline" onclick={handleClose} disabled={saving} type="button">
607
+ <Button
608
+ variant="outline"
609
+ onclick={handleClose}
610
+ disabled={saving}
611
+ type="button"
612
+ data-testid="cancel-button"
613
+ >
591
614
  Cancel
592
615
  </Button>
593
616
  <Button
@@ -596,6 +619,7 @@
596
619
  onclick={() => formElement?.requestSubmit()}
597
620
  disabled={saving}
598
621
  isLoading={saving}
622
+ data-testid="save-user-button"
599
623
  >
600
624
  {mode === 'create' ? 'Create User' : 'Save Changes'}
601
625
  </Button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makolabs/ripple",
3
- "version": "1.6.9",
3
+ "version": "1.7.2",
4
4
  "description": "Simple Svelte 5 powered component library ✨",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -18,6 +18,10 @@
18
18
  "format": "prettier --write .",
19
19
  "lint": "prettier --check . && eslint .",
20
20
  "test:unit": "vitest",
21
+ "test:e2e": "playwright test",
22
+ "test:e2e:ui": "playwright test --ui",
23
+ "test:e2e:debug": "playwright test --debug",
24
+ "test:e2e:report": "playwright show-report",
21
25
  "test": "npm run test:unit -- --run",
22
26
  "pub:minor": "git add . && git commit -m \"chore: prepare for publish minor\" && npm version minor && git push --follow-tags && npm publish",
23
27
  "pub:patch": "git add . && git commit -m \"chore: prepare for publish patch\" && npm version patch && git push --follow-tags && npm publish",