@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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Modal, Button, Color, getUserDisplayName } from '../index.js';
|
|
3
|
+
import RoleCard from './RoleCard.svelte';
|
|
4
|
+
import ApiKeyField from './ApiKeyField.svelte';
|
|
5
|
+
import UserIdentityCard from './UserIdentityCard.svelte';
|
|
6
|
+
import type { UserApproveModalProps } from '../index.js';
|
|
7
|
+
import { toast } from 'svelte-sonner';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
open = $bindable(false),
|
|
11
|
+
user,
|
|
12
|
+
roles = [],
|
|
13
|
+
onapprove,
|
|
14
|
+
onclose
|
|
15
|
+
}: UserApproveModalProps = $props();
|
|
16
|
+
|
|
17
|
+
let selectedRole = $state<string>('');
|
|
18
|
+
let approving = $state(false);
|
|
19
|
+
let issuedKey = $state<string | null>(null);
|
|
20
|
+
|
|
21
|
+
$effect(() => {
|
|
22
|
+
if (!open) {
|
|
23
|
+
selectedRole = '';
|
|
24
|
+
approving = false;
|
|
25
|
+
issuedKey = null;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const canApprove = $derived(!!selectedRole && !approving && !!user);
|
|
30
|
+
|
|
31
|
+
async function handleApprove() {
|
|
32
|
+
if (!user || !selectedRole) return;
|
|
33
|
+
approving = true;
|
|
34
|
+
try {
|
|
35
|
+
const permissions = roles.find((r) => r.value === selectedRole)?.permissions ?? [];
|
|
36
|
+
const result = await onapprove({
|
|
37
|
+
userId: user.id,
|
|
38
|
+
role: selectedRole,
|
|
39
|
+
permissions: [...permissions]
|
|
40
|
+
});
|
|
41
|
+
issuedKey = result.apiKey;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
toast.error(error instanceof Error ? error.message : 'Failed to approve user');
|
|
44
|
+
} finally {
|
|
45
|
+
approving = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function handleDone() {
|
|
50
|
+
open = false;
|
|
51
|
+
onclose();
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<Modal
|
|
56
|
+
bind:open
|
|
57
|
+
title={issuedKey ? 'API key generated' : 'Approve user'}
|
|
58
|
+
description={issuedKey
|
|
59
|
+
? 'Copy this key now — for security it will not be shown again.'
|
|
60
|
+
: user
|
|
61
|
+
? `Assign a role to ${getUserDisplayName(user)} and generate their first API key.`
|
|
62
|
+
: undefined}
|
|
63
|
+
size="md"
|
|
64
|
+
footerAlign="end"
|
|
65
|
+
onclose={handleDone}
|
|
66
|
+
>
|
|
67
|
+
{#if !issuedKey}
|
|
68
|
+
<div class="flex flex-col gap-4">
|
|
69
|
+
<UserIdentityCard {user} />
|
|
70
|
+
|
|
71
|
+
{#if roles.length > 0}
|
|
72
|
+
<div>
|
|
73
|
+
<span class="text-default-700 mb-2 block text-sm font-medium">
|
|
74
|
+
Role <span class="text-danger-500">*</span>
|
|
75
|
+
</span>
|
|
76
|
+
<div class="grid grid-cols-1 gap-2">
|
|
77
|
+
{#each roles as role, index (`${role.value}-${index}`)}
|
|
78
|
+
<RoleCard
|
|
79
|
+
{role}
|
|
80
|
+
selected={selectedRole === role.value}
|
|
81
|
+
onclick={() => (selectedRole = role.value)}
|
|
82
|
+
testId="approve-role-{role.value}"
|
|
83
|
+
/>
|
|
84
|
+
{/each}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
{:else}
|
|
88
|
+
<p class="text-danger-600 text-xs">
|
|
89
|
+
No roles configured — pass a `roles` prop to enable approval.
|
|
90
|
+
</p>
|
|
91
|
+
{/if}
|
|
92
|
+
</div>
|
|
93
|
+
{:else}
|
|
94
|
+
<ApiKeyField
|
|
95
|
+
value={issuedKey}
|
|
96
|
+
label={undefined}
|
|
97
|
+
copyable
|
|
98
|
+
toggleable={false}
|
|
99
|
+
helperText="Store this key in your password manager or pass it to the user via a secure channel."
|
|
100
|
+
testId="approve-issued-key"
|
|
101
|
+
/>
|
|
102
|
+
{/if}
|
|
103
|
+
|
|
104
|
+
{#snippet footer()}
|
|
105
|
+
{#if !issuedKey}
|
|
106
|
+
<Button variant="outline" onclick={handleDone}>Cancel</Button>
|
|
107
|
+
<Button
|
|
108
|
+
color={Color.PRIMARY}
|
|
109
|
+
onclick={handleApprove}
|
|
110
|
+
disabled={!canApprove}
|
|
111
|
+
loading={approving}
|
|
112
|
+
testId="approve-confirm"
|
|
113
|
+
>
|
|
114
|
+
Approve & Generate Key
|
|
115
|
+
</Button>
|
|
116
|
+
{:else}
|
|
117
|
+
<Button color={Color.PRIMARY} onclick={handleDone} testId="approve-done">Done</Button>
|
|
118
|
+
{/if}
|
|
119
|
+
{/snippet}
|
|
120
|
+
</Modal>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../helper/cls.js';
|
|
3
|
+
import { getUserDisplayName, getUserInitials } from './user-management.js';
|
|
4
|
+
import type { ClassValue } from 'tailwind-variants';
|
|
5
|
+
import type { User } from '../index.js';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
user: User | null;
|
|
9
|
+
/** Optional chip rendered on the right (e.g. current role label). */
|
|
10
|
+
roleLabel?: string;
|
|
11
|
+
class?: ClassValue;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { user, roleLabel, class: className }: Props = $props();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
{#if user}
|
|
18
|
+
<div
|
|
19
|
+
class={cn(
|
|
20
|
+
'bg-default-50 border-default-200 flex items-center gap-3 rounded-lg border p-3',
|
|
21
|
+
className
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
<div class="h-10 w-10 shrink-0">
|
|
25
|
+
{#if user.image_url}
|
|
26
|
+
<img class="h-10 w-10 rounded-full" src={user.image_url} alt="" />
|
|
27
|
+
{:else}
|
|
28
|
+
<div class="bg-default-300 flex h-10 w-10 items-center justify-center rounded-full">
|
|
29
|
+
<span class="text-default-700 text-sm font-medium">
|
|
30
|
+
{getUserInitials(user)}
|
|
31
|
+
</span>
|
|
32
|
+
</div>
|
|
33
|
+
{/if}
|
|
34
|
+
</div>
|
|
35
|
+
<div class="min-w-0 flex-1">
|
|
36
|
+
<div class="text-default-900 truncate text-sm font-medium">
|
|
37
|
+
{getUserDisplayName(user)}
|
|
38
|
+
</div>
|
|
39
|
+
{#if user.email_addresses?.[0]}
|
|
40
|
+
<div class="text-default-500 truncate text-xs">
|
|
41
|
+
{user.email_addresses[0].email_address}
|
|
42
|
+
</div>
|
|
43
|
+
{/if}
|
|
44
|
+
</div>
|
|
45
|
+
{#if roleLabel}
|
|
46
|
+
<span
|
|
47
|
+
class="border-primary-200 bg-primary-50 text-primary-700 shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium"
|
|
48
|
+
>
|
|
49
|
+
{roleLabel}
|
|
50
|
+
</span>
|
|
51
|
+
{/if}
|
|
52
|
+
</div>
|
|
53
|
+
{/if}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ClassValue } from 'tailwind-variants';
|
|
2
|
+
import type { User } from '../index.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
user: User | null;
|
|
5
|
+
/** Optional chip rendered on the right (e.g. current role label). */
|
|
6
|
+
roleLabel?: string;
|
|
7
|
+
class?: ClassValue;
|
|
8
|
+
}
|
|
9
|
+
declare const UserIdentityCard: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type UserIdentityCard = ReturnType<typeof UserIdentityCard>;
|
|
11
|
+
export default UserIdentityCard;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
3
|
import { toast } from 'svelte-sonner';
|
|
4
|
-
import { PageHeader, Button, cn } from '../index.js';
|
|
4
|
+
import { PageHeader, Button, TabGroup, cn } from '../index.js';
|
|
5
5
|
import UserTable from './UserTable.svelte';
|
|
6
6
|
import UserModal from './UserModal.svelte';
|
|
7
7
|
import UserViewModal from './UserViewModal.svelte';
|
|
8
|
+
import UserApproveModal from './UserApproveModal.svelte';
|
|
8
9
|
import type {
|
|
9
10
|
User,
|
|
10
11
|
UserManagementProps,
|
|
@@ -42,6 +43,23 @@
|
|
|
42
43
|
let selectedUsers = new SvelteSet<string>();
|
|
43
44
|
let bulkAction = $state<'delete' | ''>('');
|
|
44
45
|
|
|
46
|
+
// Pending approval state — gated on the FULL workflow (list + approve + reject).
|
|
47
|
+
// If an adapter exposes only listing without both mutations, the tab stays hidden
|
|
48
|
+
// to avoid action buttons that throw or silently no-op.
|
|
49
|
+
const pendingEnabled = $derived(
|
|
50
|
+
typeof adapter.getPendingUsers === 'function' &&
|
|
51
|
+
typeof adapter.approveUser === 'function' &&
|
|
52
|
+
typeof adapter.rejectUser === 'function'
|
|
53
|
+
);
|
|
54
|
+
let activeTab = $state<'active' | 'pending'>('active');
|
|
55
|
+
let pendingUsers = $state<User[]>([]);
|
|
56
|
+
let totalPending = $state(0);
|
|
57
|
+
let pendingPage = $state(1);
|
|
58
|
+
let pendingPageSize = $state(10);
|
|
59
|
+
let pendingLoading = $state(false);
|
|
60
|
+
let showApproveModal = $state(false);
|
|
61
|
+
let userToApprove = $state<User | null>(null);
|
|
62
|
+
|
|
45
63
|
// Derived states
|
|
46
64
|
const hasSelectedUsers = $derived(selectedUsers.size > 0);
|
|
47
65
|
|
|
@@ -267,9 +285,118 @@
|
|
|
267
285
|
}
|
|
268
286
|
}
|
|
269
287
|
|
|
288
|
+
// Pending users: load + refresh + approve + reject
|
|
289
|
+
async function loadPendingUsers() {
|
|
290
|
+
if (!adapter.getPendingUsers) return;
|
|
291
|
+
try {
|
|
292
|
+
pendingLoading = true;
|
|
293
|
+
const result = await adapter.getPendingUsers({
|
|
294
|
+
page: pendingPage,
|
|
295
|
+
pageSize: pendingPageSize
|
|
296
|
+
});
|
|
297
|
+
pendingUsers = result.users.map((u) => ({ ...u }));
|
|
298
|
+
totalPending = result.totalUsers;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error('Error loading pending users:', error);
|
|
301
|
+
toast.error('Failed to load pending users');
|
|
302
|
+
} finally {
|
|
303
|
+
pendingLoading = false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function refreshPendingQuery() {
|
|
308
|
+
if (!adapter.getPendingUsers) return;
|
|
309
|
+
const fn = adapter.getPendingUsers as typeof adapter.getPendingUsers & {
|
|
310
|
+
refresh?: () => Promise<unknown>;
|
|
311
|
+
};
|
|
312
|
+
if (typeof fn.refresh === 'function') {
|
|
313
|
+
await fn.refresh();
|
|
314
|
+
}
|
|
315
|
+
await loadPendingUsers();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function openApproveModal(user: User) {
|
|
319
|
+
userToApprove = user;
|
|
320
|
+
showApproveModal = true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function handleApproveUser({
|
|
324
|
+
userId,
|
|
325
|
+
role,
|
|
326
|
+
permissions
|
|
327
|
+
}: {
|
|
328
|
+
userId: string;
|
|
329
|
+
role: string;
|
|
330
|
+
permissions: string[];
|
|
331
|
+
}) {
|
|
332
|
+
if (!adapter.approveUser) {
|
|
333
|
+
throw new Error('approveUser is not configured on the adapter');
|
|
334
|
+
}
|
|
335
|
+
const result = await adapter.approveUser({ userId, role, permissions });
|
|
336
|
+
// The user is approved AND we hold their one-time API key. A refresh failure
|
|
337
|
+
// here must NOT lose that key — surface a soft warning instead of throwing.
|
|
338
|
+
try {
|
|
339
|
+
await Promise.all([refreshPendingQuery(), refreshUsersQuery()]);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
console.error('Error refreshing users after approval:', error);
|
|
342
|
+
toast.warning(
|
|
343
|
+
'User approved, but the lists failed to refresh. Reload the page to sync the latest state.'
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function handleRejectUser(user: User) {
|
|
350
|
+
if (!adapter.rejectUser) return;
|
|
351
|
+
if (
|
|
352
|
+
!confirm(
|
|
353
|
+
`Reject ${user.email_addresses?.[0]?.email_address ?? user.id}? This permanently deletes them from your identity provider.`
|
|
354
|
+
)
|
|
355
|
+
) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// Optimistic removal — capture both previous list AND total so a paginated
|
|
359
|
+
// failure rollback restores the true count, not just the visible page length.
|
|
360
|
+
const previous = pendingUsers;
|
|
361
|
+
const previousTotal = totalPending;
|
|
362
|
+
pendingUsers = pendingUsers.filter((u) => u.id !== user.id);
|
|
363
|
+
totalPending = Math.max(0, totalPending - 1);
|
|
364
|
+
try {
|
|
365
|
+
await adapter.rejectUser(user.id);
|
|
366
|
+
await refreshPendingQuery();
|
|
367
|
+
} catch (error) {
|
|
368
|
+
console.error('Error rejecting user:', error);
|
|
369
|
+
toast.error('Failed to reject user');
|
|
370
|
+
pendingUsers = previous;
|
|
371
|
+
totalPending = previousTotal;
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function handlePendingPageChange(page: number) {
|
|
377
|
+
pendingPage = page;
|
|
378
|
+
loadPendingUsers();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function handlePendingPageSizeChange(size: number) {
|
|
382
|
+
pendingPageSize = size;
|
|
383
|
+
pendingPage = 1;
|
|
384
|
+
loadPendingUsers();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function handleTabChange(value: string) {
|
|
388
|
+
activeTab = value === 'pending' ? 'pending' : 'active';
|
|
389
|
+
if (activeTab === 'pending') {
|
|
390
|
+
loadPendingUsers();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
270
394
|
// Initialize on mount
|
|
271
395
|
onMount(async () => {
|
|
272
396
|
await loadUsers();
|
|
397
|
+
if (pendingEnabled) {
|
|
398
|
+
await loadPendingUsers();
|
|
399
|
+
}
|
|
273
400
|
});
|
|
274
401
|
</script>
|
|
275
402
|
|
|
@@ -287,14 +414,29 @@
|
|
|
287
414
|
layout="horizontal"
|
|
288
415
|
class="mb-6"
|
|
289
416
|
>
|
|
290
|
-
|
|
291
|
-
{
|
|
292
|
-
|
|
293
|
-
|
|
417
|
+
{#if activeTab === 'active'}
|
|
418
|
+
<Button onclick={openCreateModal} color="primary">
|
|
419
|
+
{@render PlusIcon()}
|
|
420
|
+
Add User
|
|
421
|
+
</Button>
|
|
422
|
+
{/if}
|
|
294
423
|
</PageHeader>
|
|
295
424
|
|
|
425
|
+
{#if pendingEnabled}
|
|
426
|
+
<TabGroup
|
|
427
|
+
tabs={[
|
|
428
|
+
{ value: 'active', label: `Active (${totalUsers})` },
|
|
429
|
+
{ value: 'pending', label: `Pending (${totalPending})` }
|
|
430
|
+
]}
|
|
431
|
+
selected={activeTab}
|
|
432
|
+
onchange={handleTabChange}
|
|
433
|
+
class="mb-4"
|
|
434
|
+
testId="user-management-tabs"
|
|
435
|
+
/>
|
|
436
|
+
{/if}
|
|
437
|
+
|
|
296
438
|
<!-- Bulk Actions Bar -->
|
|
297
|
-
{#if hasSelectedUsers}
|
|
439
|
+
{#if activeTab === 'active' && hasSelectedUsers}
|
|
298
440
|
<div
|
|
299
441
|
class="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4"
|
|
300
442
|
>
|
|
@@ -319,19 +461,33 @@
|
|
|
319
461
|
{/if}
|
|
320
462
|
|
|
321
463
|
<!-- Users Table Component -->
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
464
|
+
{#if activeTab === 'active'}
|
|
465
|
+
<UserTable
|
|
466
|
+
{users}
|
|
467
|
+
{loading}
|
|
468
|
+
{currentPage}
|
|
469
|
+
{pageSize}
|
|
470
|
+
{totalUsers}
|
|
471
|
+
onpagechange={handlePageChange}
|
|
472
|
+
onpagesizechange={handlePageSizeChange}
|
|
473
|
+
onsort={handleSort}
|
|
474
|
+
onview={openViewModal}
|
|
475
|
+
onedit={openEditModal}
|
|
476
|
+
ondelete={handleDeleteUser}
|
|
477
|
+
/>
|
|
478
|
+
{:else}
|
|
479
|
+
<UserTable
|
|
480
|
+
users={pendingUsers}
|
|
481
|
+
loading={pendingLoading}
|
|
482
|
+
currentPage={pendingPage}
|
|
483
|
+
pageSize={pendingPageSize}
|
|
484
|
+
totalUsers={totalPending}
|
|
485
|
+
onpagechange={handlePendingPageChange}
|
|
486
|
+
onpagesizechange={handlePendingPageSizeChange}
|
|
487
|
+
onapprove={openApproveModal}
|
|
488
|
+
onreject={handleRejectUser}
|
|
489
|
+
/>
|
|
490
|
+
{/if}
|
|
335
491
|
|
|
336
492
|
<!-- User View Modal -->
|
|
337
493
|
<UserViewModal
|
|
@@ -352,4 +508,15 @@
|
|
|
352
508
|
onsave={handleUserSave}
|
|
353
509
|
onclose={handleModalClose}
|
|
354
510
|
/>
|
|
511
|
+
|
|
512
|
+
<!-- Approve Pending User Modal -->
|
|
513
|
+
{#if pendingEnabled}
|
|
514
|
+
<UserApproveModal
|
|
515
|
+
bind:open={showApproveModal}
|
|
516
|
+
user={userToApprove}
|
|
517
|
+
{roles}
|
|
518
|
+
onapprove={handleApproveUser}
|
|
519
|
+
onclose={() => (userToApprove = null)}
|
|
520
|
+
/>
|
|
521
|
+
{/if}
|
|
355
522
|
</div>
|