@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
|
@@ -20,6 +20,7 @@ import type { User, GetUsersOptions, GetUsersResult } from '../index.js';
|
|
|
20
20
|
*/
|
|
21
21
|
export declare function resetState(options?: {
|
|
22
22
|
initialUsers?: User[];
|
|
23
|
+
initialPendingUsers?: User[];
|
|
23
24
|
simulateDelay?: boolean;
|
|
24
25
|
delayMs?: number;
|
|
25
26
|
}): Promise<void>;
|
|
@@ -77,3 +78,43 @@ export declare function generateApiKey(options: {
|
|
|
77
78
|
apiKey: string;
|
|
78
79
|
message: string;
|
|
79
80
|
}>;
|
|
81
|
+
/**
|
|
82
|
+
* Verify an API key (mock implementation).
|
|
83
|
+
* Matches UserManagementAdapter.verifyToken signature.
|
|
84
|
+
*
|
|
85
|
+
* The mock only accepts keys that exactly match an existing active user's
|
|
86
|
+
* `private_metadata.mako_api_key`; unknown/revoked keys are rejected so the
|
|
87
|
+
* failure UI is reachable.
|
|
88
|
+
*/
|
|
89
|
+
export declare function verifyToken(options: {
|
|
90
|
+
apiKey: string;
|
|
91
|
+
}): Promise<{
|
|
92
|
+
valid: boolean;
|
|
93
|
+
scopes?: string[];
|
|
94
|
+
error?: string;
|
|
95
|
+
sub?: string;
|
|
96
|
+
client_id?: string;
|
|
97
|
+
}>;
|
|
98
|
+
/**
|
|
99
|
+
* List users awaiting approval (registered in identity provider but not
|
|
100
|
+
* yet onboarded — no org / no API key).
|
|
101
|
+
* Matches UserManagementAdapter.getPendingUsers signature.
|
|
102
|
+
*/
|
|
103
|
+
export declare function getPendingUsers(options: GetUsersOptions): Promise<GetUsersResult>;
|
|
104
|
+
/**
|
|
105
|
+
* Approve a pending user — assigns the chosen role and generates an API key.
|
|
106
|
+
* Moves the user from the pending list to the active list.
|
|
107
|
+
* Matches UserManagementAdapter.approveUser signature.
|
|
108
|
+
*/
|
|
109
|
+
export declare function approveUser(input: {
|
|
110
|
+
userId: string;
|
|
111
|
+
role: string;
|
|
112
|
+
permissions?: string[];
|
|
113
|
+
}): Promise<{
|
|
114
|
+
apiKey: string;
|
|
115
|
+
}>;
|
|
116
|
+
/**
|
|
117
|
+
* Reject a pending user — permanently removes them from the identity provider.
|
|
118
|
+
* Matches UserManagementAdapter.rejectUser signature.
|
|
119
|
+
*/
|
|
120
|
+
export declare function rejectUser(userId: string): Promise<void>;
|
|
@@ -16,13 +16,17 @@
|
|
|
16
16
|
*/
|
|
17
17
|
// Internal module-level state
|
|
18
18
|
let mockUsers = [];
|
|
19
|
+
let mockPendingUsers = [];
|
|
19
20
|
let simulateDelay = false;
|
|
20
21
|
let delayMs = 300;
|
|
21
22
|
/**
|
|
22
23
|
* Reset mock adapter state
|
|
23
24
|
*/
|
|
24
25
|
export async function resetState(options = {}) {
|
|
25
|
-
|
|
26
|
+
// Deep-clone fixtures so internal mutations (splice in approveUser/rejectUser)
|
|
27
|
+
// don't poison caller-owned arrays — keeps tests order-independent.
|
|
28
|
+
mockUsers = structuredClone(options.initialUsers ?? []);
|
|
29
|
+
mockPendingUsers = structuredClone(options.initialPendingUsers ?? []);
|
|
26
30
|
simulateDelay = options.simulateDelay ?? false;
|
|
27
31
|
delayMs = options.delayMs ?? 300;
|
|
28
32
|
}
|
|
@@ -214,3 +218,83 @@ export async function generateApiKey(options) {
|
|
|
214
218
|
: 'API key generated successfully'
|
|
215
219
|
};
|
|
216
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Verify an API key (mock implementation).
|
|
223
|
+
* Matches UserManagementAdapter.verifyToken signature.
|
|
224
|
+
*
|
|
225
|
+
* The mock only accepts keys that exactly match an existing active user's
|
|
226
|
+
* `private_metadata.mako_api_key`; unknown/revoked keys are rejected so the
|
|
227
|
+
* failure UI is reachable.
|
|
228
|
+
*/
|
|
229
|
+
export async function verifyToken(options) {
|
|
230
|
+
await delay();
|
|
231
|
+
const owner = mockUsers.find((u) => u.private_metadata &&
|
|
232
|
+
typeof u.private_metadata === 'object' &&
|
|
233
|
+
'mako_api_key' in u.private_metadata &&
|
|
234
|
+
u.private_metadata.mako_api_key === options.apiKey);
|
|
235
|
+
if (!owner) {
|
|
236
|
+
return {
|
|
237
|
+
valid: false,
|
|
238
|
+
error: 'API key has been revoked or is malformed'
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// Real Mako Auth returns `sub` as the user's email — mirror that here so
|
|
242
|
+
// downstream code that key-lookups by email behaves identically.
|
|
243
|
+
return {
|
|
244
|
+
valid: true,
|
|
245
|
+
sub: owner.email_addresses?.[0]?.email_address ?? owner.id,
|
|
246
|
+
client_id: 'mock-client',
|
|
247
|
+
scopes: owner.permissions ?? []
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* List users awaiting approval (registered in identity provider but not
|
|
252
|
+
* yet onboarded — no org / no API key).
|
|
253
|
+
* Matches UserManagementAdapter.getPendingUsers signature.
|
|
254
|
+
*/
|
|
255
|
+
export async function getPendingUsers(options) {
|
|
256
|
+
await delay();
|
|
257
|
+
const start = (options.page - 1) * options.pageSize;
|
|
258
|
+
const end = start + options.pageSize;
|
|
259
|
+
return {
|
|
260
|
+
users: mockPendingUsers.slice(start, end),
|
|
261
|
+
totalUsers: mockPendingUsers.length
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Approve a pending user — assigns the chosen role and generates an API key.
|
|
266
|
+
* Moves the user from the pending list to the active list.
|
|
267
|
+
* Matches UserManagementAdapter.approveUser signature.
|
|
268
|
+
*/
|
|
269
|
+
export async function approveUser(input) {
|
|
270
|
+
await delay();
|
|
271
|
+
// Fail fast on empty role — no key minted, no state mutation.
|
|
272
|
+
if (!input.role?.trim()) {
|
|
273
|
+
throw new Error('Role is required to approve a user');
|
|
274
|
+
}
|
|
275
|
+
const idx = mockPendingUsers.findIndex((u) => u.id === input.userId);
|
|
276
|
+
if (idx === -1) {
|
|
277
|
+
throw new Error(`Pending user ${input.userId} not found`);
|
|
278
|
+
}
|
|
279
|
+
const user = mockPendingUsers[idx];
|
|
280
|
+
const apiKey = `mock_api_key_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
281
|
+
mockPendingUsers.splice(idx, 1);
|
|
282
|
+
mockUsers.push({
|
|
283
|
+
...user,
|
|
284
|
+
role: input.role,
|
|
285
|
+
private_metadata: { ...(user.private_metadata || {}), mako_api_key: apiKey }
|
|
286
|
+
});
|
|
287
|
+
return { apiKey };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Reject a pending user — permanently removes them from the identity provider.
|
|
291
|
+
* Matches UserManagementAdapter.rejectUser signature.
|
|
292
|
+
*/
|
|
293
|
+
export async function rejectUser(userId) {
|
|
294
|
+
await delay();
|
|
295
|
+
const idx = mockPendingUsers.findIndex((u) => u.id === userId);
|
|
296
|
+
if (idx === -1) {
|
|
297
|
+
throw new Error(`Pending user ${userId} not found`);
|
|
298
|
+
}
|
|
299
|
+
mockPendingUsers.splice(idx, 1);
|
|
300
|
+
}
|
|
@@ -21,6 +21,29 @@ export declare const generateApiKey: import("@sveltejs/kit").RemoteCommand<{
|
|
|
21
21
|
apiKey: string;
|
|
22
22
|
message: string;
|
|
23
23
|
}>>;
|
|
24
|
+
/**
|
|
25
|
+
* List users who exist in the identity provider but are NOT yet members of
|
|
26
|
+
* ALLOWED_ORG_ID. Treats org membership as the "approved" signal — anyone
|
|
27
|
+
* authenticated against the app without org membership is "pending".
|
|
28
|
+
*/
|
|
29
|
+
export declare const getPendingUsers: import("@sveltejs/kit").RemoteCommand<GetUsersOptions, Promise<GetUsersResult>>;
|
|
30
|
+
/**
|
|
31
|
+
* Approve a pending user: add them to ALLOWED_ORG_ID with the chosen Clerk role,
|
|
32
|
+
* then mint an API key carrying the supplied scope set. The caller (the modal)
|
|
33
|
+
* resolves the role's scopes from the `roles` prop and passes them in.
|
|
34
|
+
*/
|
|
35
|
+
export declare const approveUser: import("@sveltejs/kit").RemoteCommand<{
|
|
36
|
+
userId: string;
|
|
37
|
+
role: string;
|
|
38
|
+
permissions: string[];
|
|
39
|
+
}, Promise<{
|
|
40
|
+
apiKey: string;
|
|
41
|
+
}>>;
|
|
42
|
+
/**
|
|
43
|
+
* Reject a pending user — permanently deletes them from the identity provider.
|
|
44
|
+
* Destructive and irreversible; the user loses login, history, everything.
|
|
45
|
+
*/
|
|
46
|
+
export declare const rejectUser: import("@sveltejs/kit").RemoteCommand<string, Promise<void>>;
|
|
24
47
|
export declare const verifyToken: import("@sveltejs/kit").RemoteCommand<{
|
|
25
48
|
apiKey: string;
|
|
26
49
|
}, Promise<{
|
|
@@ -772,6 +772,134 @@ export const generateApiKey = command('unchecked', async (options) => {
|
|
|
772
772
|
throw new Error(`Failed to generate API key: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
773
773
|
}
|
|
774
774
|
});
|
|
775
|
+
/**
|
|
776
|
+
* Fetch the set of Clerk user IDs that already belong to ALLOWED_ORG_ID.
|
|
777
|
+
* Pages through Clerk's memberships endpoint until exhausted.
|
|
778
|
+
*/
|
|
779
|
+
async function fetchOrgMemberIds() {
|
|
780
|
+
if (!ORGANIZATION_ID) {
|
|
781
|
+
throw new Error('ALLOWED_ORG_ID environment variable is required');
|
|
782
|
+
}
|
|
783
|
+
const memberIds = new Set();
|
|
784
|
+
const limit = 500;
|
|
785
|
+
let offset = 0;
|
|
786
|
+
while (true) {
|
|
787
|
+
const data = await makeClerkRequest(`/organizations/${ORGANIZATION_ID}/memberships?limit=${limit}&offset=${offset}`);
|
|
788
|
+
const list = Array.isArray(data)
|
|
789
|
+
? data
|
|
790
|
+
: (data?.data ?? []);
|
|
791
|
+
for (const m of list) {
|
|
792
|
+
const uid = m?.public_user_data?.user_id;
|
|
793
|
+
if (uid)
|
|
794
|
+
memberIds.add(uid);
|
|
795
|
+
}
|
|
796
|
+
if (list.length < limit)
|
|
797
|
+
break;
|
|
798
|
+
offset += limit;
|
|
799
|
+
}
|
|
800
|
+
return memberIds;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* List users who exist in the identity provider but are NOT yet members of
|
|
804
|
+
* ALLOWED_ORG_ID. Treats org membership as the "approved" signal — anyone
|
|
805
|
+
* authenticated against the app without org membership is "pending".
|
|
806
|
+
*/
|
|
807
|
+
export const getPendingUsers = command('unchecked', async (options) => {
|
|
808
|
+
log.trace('getPendingUsers', 'Called with options:', options);
|
|
809
|
+
try {
|
|
810
|
+
const memberIds = await fetchOrgMemberIds();
|
|
811
|
+
// Pull all users (paginated) — Clerk has no "not in org" filter, so
|
|
812
|
+
// we filter client-side. For typical org sizes this is one or two API calls.
|
|
813
|
+
const all = [];
|
|
814
|
+
const limit = 500;
|
|
815
|
+
let offset = 0;
|
|
816
|
+
while (true) {
|
|
817
|
+
const params = new URLSearchParams({
|
|
818
|
+
limit: limit.toString(),
|
|
819
|
+
offset: offset.toString(),
|
|
820
|
+
order_by: '-created_at'
|
|
821
|
+
});
|
|
822
|
+
if (options.query)
|
|
823
|
+
params.append('query', options.query);
|
|
824
|
+
const page = await makeClerkRequest(`/users?${params}`);
|
|
825
|
+
const users = Array.isArray(page) ? page : (page?.data ?? []);
|
|
826
|
+
all.push(...users);
|
|
827
|
+
if (users.length < limit)
|
|
828
|
+
break;
|
|
829
|
+
offset += limit;
|
|
830
|
+
}
|
|
831
|
+
const pending = all.filter((u) => !memberIds.has(u.id));
|
|
832
|
+
const start = (options.page - 1) * options.pageSize;
|
|
833
|
+
const slice = pending.slice(start, start + options.pageSize);
|
|
834
|
+
log.trace('getPendingUsers', 'Total users:', all.length, 'org members:', memberIds.size, 'pending:', pending.length);
|
|
835
|
+
return JSON.parse(JSON.stringify({
|
|
836
|
+
users: slice,
|
|
837
|
+
totalUsers: pending.length
|
|
838
|
+
}));
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
log.error('getPendingUsers', 'Error:', error);
|
|
842
|
+
throw new Error(`Failed to fetch pending users: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
/**
|
|
846
|
+
* Approve a pending user: add them to ALLOWED_ORG_ID with the chosen Clerk role,
|
|
847
|
+
* then mint an API key carrying the supplied scope set. The caller (the modal)
|
|
848
|
+
* resolves the role's scopes from the `roles` prop and passes them in.
|
|
849
|
+
*/
|
|
850
|
+
export const approveUser = command('unchecked', async (input) => {
|
|
851
|
+
log.trace('approveUser', 'Called for userId:', input.userId, 'role:', input.role, 'permissions:', input.permissions);
|
|
852
|
+
if (!input.role?.trim()) {
|
|
853
|
+
throw new Error('Role is required to approve a user');
|
|
854
|
+
}
|
|
855
|
+
if (!input.permissions?.length) {
|
|
856
|
+
throw new Error('At least one permission scope is required to approve a user');
|
|
857
|
+
}
|
|
858
|
+
try {
|
|
859
|
+
const clerkRole = input.role.startsWith('org:') ? input.role : `org:${input.role}`;
|
|
860
|
+
await makeClerkRequest(`/organizations/${ORGANIZATION_ID}/memberships`, {
|
|
861
|
+
method: 'POST',
|
|
862
|
+
body: JSON.stringify({ user_id: input.userId, role: clerkRole })
|
|
863
|
+
});
|
|
864
|
+
const user = await makeClerkRequest(`/users/${input.userId}`);
|
|
865
|
+
const email = user.email_addresses?.[0]?.email_address;
|
|
866
|
+
if (!email) {
|
|
867
|
+
throw new Error('User has no email address');
|
|
868
|
+
}
|
|
869
|
+
const adminKeyResult = await createUserPermissions(email, input.permissions);
|
|
870
|
+
const apiKey = adminKeyResult?.data?.key;
|
|
871
|
+
if (!apiKey) {
|
|
872
|
+
throw new Error('Failed to mint API key during approval');
|
|
873
|
+
}
|
|
874
|
+
await makeClerkRequest(`/users/${input.userId}`, {
|
|
875
|
+
method: 'PATCH',
|
|
876
|
+
body: JSON.stringify({
|
|
877
|
+
private_metadata: { ...(user.private_metadata || {}), mako_api_key: apiKey }
|
|
878
|
+
})
|
|
879
|
+
});
|
|
880
|
+
log.trace('approveUser', 'Approved and key issued');
|
|
881
|
+
return JSON.parse(JSON.stringify({ apiKey }));
|
|
882
|
+
}
|
|
883
|
+
catch (error) {
|
|
884
|
+
log.error('approveUser', 'Error:', error);
|
|
885
|
+
handleClerkError(error, 'Failed to approve user');
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
/**
|
|
889
|
+
* Reject a pending user — permanently deletes them from the identity provider.
|
|
890
|
+
* Destructive and irreversible; the user loses login, history, everything.
|
|
891
|
+
*/
|
|
892
|
+
export const rejectUser = command('unchecked', async (userId) => {
|
|
893
|
+
log.trace('rejectUser', 'Called for userId:', userId);
|
|
894
|
+
try {
|
|
895
|
+
await makeClerkRequest(`/users/${userId}`, { method: 'DELETE' });
|
|
896
|
+
log.trace('rejectUser', 'Deleted successfully');
|
|
897
|
+
}
|
|
898
|
+
catch (error) {
|
|
899
|
+
log.error('rejectUser', 'Error:', error);
|
|
900
|
+
throw new Error(`Failed to reject user: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
901
|
+
}
|
|
902
|
+
});
|
|
775
903
|
export const verifyToken = command('unchecked', async (options) => {
|
|
776
904
|
log.trace('verifyToken', 'Called');
|
|
777
905
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export type { StepperProps, StepperStep, StepState, StepperOrientation } from '.
|
|
|
60
60
|
export type { ActivityItemBadge, ActivityItemAction, ActivityItem, ActivityListProps, ActivityListSize } from './layout/activity-list/activity-list-types.js';
|
|
61
61
|
export type { FileUploadProps, FileUploadSize, FilePreviewProps, UploadedFile, StagedFile } from './elements/file-upload/file-upload-types.js';
|
|
62
62
|
export type { ChatMessageType, StreamingCallback, ChatAction, ChatMessage, ChatResponse, QuickAction, FileBrowserProps } from './ai/ai-types.js';
|
|
63
|
-
export type { GetUsersOptions, GetUsersResult, UserEmail, UserPhone, User, Permission, Role, UserTableProps, UserModalProps, UserModalSavePayload, UserViewModalProps, UserManagementAdapter, UserManagementProps, FormErrors } from './user-management/user-management-types.js';
|
|
63
|
+
export type { GetUsersOptions, GetUsersResult, UserEmail, UserPhone, User, Permission, Role, UserTableProps, UserModalProps, UserModalSavePayload, UserViewModalProps, UserApproveModalProps, UserManagementAdapter, UserManagementProps, FormErrors } from './user-management/user-management-types.js';
|
|
64
64
|
export { tv, cn } from './helper/cls.js';
|
|
65
65
|
export { isRouteActive } from './helper/nav.svelte.js';
|
|
66
66
|
export { default as Button } from './button/Button.svelte';
|
|
@@ -148,3 +148,7 @@ export { default as UserManagement } from './user-management/UserManagement.svel
|
|
|
148
148
|
export { default as UserTable } from './user-management/UserTable.svelte';
|
|
149
149
|
export { default as UserModal } from './user-management/UserModal.svelte';
|
|
150
150
|
export { default as UserViewModal } from './user-management/UserViewModal.svelte';
|
|
151
|
+
export { default as UserApproveModal } from './user-management/UserApproveModal.svelte';
|
|
152
|
+
export { default as RoleCard } from './user-management/RoleCard.svelte';
|
|
153
|
+
export { default as ApiKeyField } from './user-management/ApiKeyField.svelte';
|
|
154
|
+
export { default as UserIdentityCard } from './user-management/UserIdentityCard.svelte';
|
package/dist/index.js
CHANGED
|
@@ -153,3 +153,7 @@ export { default as UserManagement } from './user-management/UserManagement.svel
|
|
|
153
153
|
export { default as UserTable } from './user-management/UserTable.svelte';
|
|
154
154
|
export { default as UserModal } from './user-management/UserModal.svelte';
|
|
155
155
|
export { default as UserViewModal } from './user-management/UserViewModal.svelte';
|
|
156
|
+
export { default as UserApproveModal } from './user-management/UserApproveModal.svelte';
|
|
157
|
+
export { default as RoleCard } from './user-management/RoleCard.svelte';
|
|
158
|
+
export { default as ApiKeyField } from './user-management/ApiKeyField.svelte';
|
|
159
|
+
export { default as UserIdentityCard } from './user-management/UserIdentityCard.svelte';
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Color } from '../index.js';
|
|
3
|
+
import { cn } from '../helper/cls.js';
|
|
4
|
+
import type { ClassValue } from 'tailwind-variants';
|
|
5
|
+
import { toast } from 'svelte-sonner';
|
|
6
|
+
|
|
7
|
+
type ActionButton = {
|
|
8
|
+
label: string;
|
|
9
|
+
loading?: boolean;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
onclick: () => void;
|
|
12
|
+
testId?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** The API key value. Empty string = no key issued yet. */
|
|
17
|
+
value: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
/** Inline error message (e.g. regenerate failure). Suppresses helperText when set. */
|
|
20
|
+
error?: string | null;
|
|
21
|
+
/** Subtle hint shown below the field when no error. */
|
|
22
|
+
helperText?: string;
|
|
23
|
+
/** Action button rendered to the right of the label (e.g. Regenerate). */
|
|
24
|
+
regenerate?: ActionButton;
|
|
25
|
+
/** Optional second action (e.g. Verify). Rendered before regenerate. */
|
|
26
|
+
verify?: ActionButton;
|
|
27
|
+
/** Show the masked → revealed eye toggle. @default true */
|
|
28
|
+
toggleable?: boolean;
|
|
29
|
+
/** Show a Copy button next to the value. @default false */
|
|
30
|
+
copyable?: boolean;
|
|
31
|
+
/** When the key is missing, what to display. @default 'No API key' */
|
|
32
|
+
emptyLabel?: string;
|
|
33
|
+
testId?: string;
|
|
34
|
+
class?: ClassValue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let {
|
|
38
|
+
value,
|
|
39
|
+
label = 'API Key',
|
|
40
|
+
error = null,
|
|
41
|
+
helperText,
|
|
42
|
+
regenerate,
|
|
43
|
+
verify,
|
|
44
|
+
toggleable = true,
|
|
45
|
+
copyable = false,
|
|
46
|
+
emptyLabel = 'No API key',
|
|
47
|
+
testId,
|
|
48
|
+
class: className
|
|
49
|
+
}: Props = $props();
|
|
50
|
+
|
|
51
|
+
let revealed = $state(false);
|
|
52
|
+
let copied = $state(false);
|
|
53
|
+
|
|
54
|
+
const masked = $derived(value ? '•'.repeat(Math.min(value.length, 40)) : '');
|
|
55
|
+
// If the field isn't toggleable, there's no way for the user to reveal it —
|
|
56
|
+
// so always show the raw value (used in one-time-reveal flows like approval).
|
|
57
|
+
const display = $derived(value ? (!toggleable || revealed ? value : masked) : emptyLabel);
|
|
58
|
+
|
|
59
|
+
async function handleCopy() {
|
|
60
|
+
if (!value) return;
|
|
61
|
+
try {
|
|
62
|
+
await navigator.clipboard.writeText(value);
|
|
63
|
+
copied = true;
|
|
64
|
+
setTimeout(() => (copied = false), 2000);
|
|
65
|
+
} catch {
|
|
66
|
+
toast.error('Failed to copy API key');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<div class={cn('w-full', className)}>
|
|
72
|
+
{#if label || regenerate || verify}
|
|
73
|
+
<div class="mb-2 flex items-center justify-between gap-3">
|
|
74
|
+
{#if label}
|
|
75
|
+
<span class="text-default-700 text-sm font-medium">{label}</span>
|
|
76
|
+
{/if}
|
|
77
|
+
{#if verify || regenerate}
|
|
78
|
+
<div class="flex items-center gap-2">
|
|
79
|
+
{#if verify}
|
|
80
|
+
<Button
|
|
81
|
+
type="button"
|
|
82
|
+
size="sm"
|
|
83
|
+
variant="link"
|
|
84
|
+
color={Color.SUCCESS}
|
|
85
|
+
onclick={verify.onclick}
|
|
86
|
+
disabled={verify.disabled || verify.loading}
|
|
87
|
+
loading={verify.loading}
|
|
88
|
+
testId={verify.testId}
|
|
89
|
+
>
|
|
90
|
+
{verify.label}
|
|
91
|
+
</Button>
|
|
92
|
+
{/if}
|
|
93
|
+
{#if regenerate}
|
|
94
|
+
<Button
|
|
95
|
+
type="button"
|
|
96
|
+
size="sm"
|
|
97
|
+
variant="link"
|
|
98
|
+
color={Color.PRIMARY}
|
|
99
|
+
onclick={regenerate.onclick}
|
|
100
|
+
disabled={regenerate.disabled || regenerate.loading}
|
|
101
|
+
loading={regenerate.loading}
|
|
102
|
+
testId={regenerate.testId}
|
|
103
|
+
>
|
|
104
|
+
{regenerate.label}
|
|
105
|
+
</Button>
|
|
106
|
+
{/if}
|
|
107
|
+
</div>
|
|
108
|
+
{/if}
|
|
109
|
+
</div>
|
|
110
|
+
{/if}
|
|
111
|
+
|
|
112
|
+
<div class="border-default-300 bg-default-50 flex items-center gap-2 rounded-lg border px-3 py-2">
|
|
113
|
+
<code
|
|
114
|
+
class="text-default-900 min-w-0 flex-1 font-mono text-sm break-all"
|
|
115
|
+
data-testid={testId ? `${testId}-value` : undefined}
|
|
116
|
+
>
|
|
117
|
+
{display}
|
|
118
|
+
</code>
|
|
119
|
+
{#if value && toggleable}
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onclick={() => (revealed = !revealed)}
|
|
123
|
+
class="text-default-500 hover:text-default-700 shrink-0 cursor-pointer"
|
|
124
|
+
aria-label={revealed ? 'Hide API key' : 'Show API key'}
|
|
125
|
+
>
|
|
126
|
+
{#if revealed}
|
|
127
|
+
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
128
|
+
<path
|
|
129
|
+
stroke-linecap="round"
|
|
130
|
+
stroke-linejoin="round"
|
|
131
|
+
stroke-width="2"
|
|
132
|
+
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"
|
|
133
|
+
></path>
|
|
134
|
+
</svg>
|
|
135
|
+
{:else}
|
|
136
|
+
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
137
|
+
<path
|
|
138
|
+
stroke-linecap="round"
|
|
139
|
+
stroke-linejoin="round"
|
|
140
|
+
stroke-width="2"
|
|
141
|
+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
142
|
+
></path>
|
|
143
|
+
<path
|
|
144
|
+
stroke-linecap="round"
|
|
145
|
+
stroke-linejoin="round"
|
|
146
|
+
stroke-width="2"
|
|
147
|
+
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"
|
|
148
|
+
></path>
|
|
149
|
+
</svg>
|
|
150
|
+
{/if}
|
|
151
|
+
</button>
|
|
152
|
+
{/if}
|
|
153
|
+
{#if value && copyable}
|
|
154
|
+
<Button type="button" size="sm" variant="outline" onclick={handleCopy}>
|
|
155
|
+
{copied ? 'Copied' : 'Copy'}
|
|
156
|
+
</Button>
|
|
157
|
+
{/if}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{#if error}
|
|
161
|
+
<p class="text-danger-500 mt-1 text-xs">{error}</p>
|
|
162
|
+
{:else if helperText}
|
|
163
|
+
<p class="text-default-500 mt-1 text-xs">{helperText}</p>
|
|
164
|
+
{/if}
|
|
165
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ClassValue } from 'tailwind-variants';
|
|
2
|
+
type ActionButton = {
|
|
3
|
+
label: string;
|
|
4
|
+
loading?: boolean;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
onclick: () => void;
|
|
7
|
+
testId?: string;
|
|
8
|
+
};
|
|
9
|
+
interface Props {
|
|
10
|
+
/** The API key value. Empty string = no key issued yet. */
|
|
11
|
+
value: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Inline error message (e.g. regenerate failure). Suppresses helperText when set. */
|
|
14
|
+
error?: string | null;
|
|
15
|
+
/** Subtle hint shown below the field when no error. */
|
|
16
|
+
helperText?: string;
|
|
17
|
+
/** Action button rendered to the right of the label (e.g. Regenerate). */
|
|
18
|
+
regenerate?: ActionButton;
|
|
19
|
+
/** Optional second action (e.g. Verify). Rendered before regenerate. */
|
|
20
|
+
verify?: ActionButton;
|
|
21
|
+
/** Show the masked → revealed eye toggle. @default true */
|
|
22
|
+
toggleable?: boolean;
|
|
23
|
+
/** Show a Copy button next to the value. @default false */
|
|
24
|
+
copyable?: boolean;
|
|
25
|
+
/** When the key is missing, what to display. @default 'No API key' */
|
|
26
|
+
emptyLabel?: string;
|
|
27
|
+
testId?: string;
|
|
28
|
+
class?: ClassValue;
|
|
29
|
+
}
|
|
30
|
+
declare const ApiKeyField: import("svelte").Component<Props, {}, "">;
|
|
31
|
+
type ApiKeyField = ReturnType<typeof ApiKeyField>;
|
|
32
|
+
export default ApiKeyField;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../helper/cls.js';
|
|
3
|
+
import type { ClassValue } from 'tailwind-variants';
|
|
4
|
+
import type { Role } from '../index.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
role: Role;
|
|
8
|
+
selected?: boolean;
|
|
9
|
+
/** When false, renders as a static card (no button, no hover). @default true */
|
|
10
|
+
interactive?: boolean;
|
|
11
|
+
/** Reduces opacity to signal "preserved selection" (e.g. admin role on edit). */
|
|
12
|
+
dimmed?: boolean;
|
|
13
|
+
onclick?: () => void;
|
|
14
|
+
testId?: string;
|
|
15
|
+
class?: ClassValue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
role,
|
|
20
|
+
selected = false,
|
|
21
|
+
interactive = true,
|
|
22
|
+
dimmed = false,
|
|
23
|
+
onclick,
|
|
24
|
+
testId,
|
|
25
|
+
class: className
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
|
|
28
|
+
const baseClass = 'rounded-lg border-2 p-2 text-left transition-all w-full block';
|
|
29
|
+
const selectedClass = 'border-blue-500 bg-blue-50';
|
|
30
|
+
const idleClass = 'border-default-200 bg-white';
|
|
31
|
+
const interactiveIdle = 'hover:border-default-300 cursor-pointer';
|
|
32
|
+
|
|
33
|
+
const composed = $derived(
|
|
34
|
+
cn(
|
|
35
|
+
baseClass,
|
|
36
|
+
selected ? selectedClass : idleClass,
|
|
37
|
+
interactive && !selected && interactiveIdle,
|
|
38
|
+
dimmed && 'opacity-75',
|
|
39
|
+
className
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
{#snippet content()}
|
|
45
|
+
<div class="flex items-center justify-between gap-2">
|
|
46
|
+
<div class="min-w-0 flex-1">
|
|
47
|
+
<h4 class="text-default-900 text-sm font-semibold">{role.label}</h4>
|
|
48
|
+
{#if role.description}
|
|
49
|
+
<p class="text-default-600 mt-1 line-clamp-2 text-xs">{role.description}</p>
|
|
50
|
+
{/if}
|
|
51
|
+
</div>
|
|
52
|
+
<div
|
|
53
|
+
class={cn(
|
|
54
|
+
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2',
|
|
55
|
+
selected ? 'border-blue-500 bg-blue-500' : 'border-default-300 bg-white'
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{#if selected}
|
|
59
|
+
<div class="h-2 w-2 rounded-full bg-white"></div>
|
|
60
|
+
{/if}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
{/snippet}
|
|
64
|
+
|
|
65
|
+
{#if interactive}
|
|
66
|
+
<button type="button" {onclick} class={composed} data-testid={testId ?? `role-${role.value}`}>
|
|
67
|
+
{@render content()}
|
|
68
|
+
</button>
|
|
69
|
+
{:else}
|
|
70
|
+
<div class={composed} data-testid={testId ?? `role-${role.value}`}>
|
|
71
|
+
{@render content()}
|
|
72
|
+
</div>
|
|
73
|
+
{/if}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ClassValue } from 'tailwind-variants';
|
|
2
|
+
import type { Role } from '../index.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
role: Role;
|
|
5
|
+
selected?: boolean;
|
|
6
|
+
/** When false, renders as a static card (no button, no hover). @default true */
|
|
7
|
+
interactive?: boolean;
|
|
8
|
+
/** Reduces opacity to signal "preserved selection" (e.g. admin role on edit). */
|
|
9
|
+
dimmed?: boolean;
|
|
10
|
+
onclick?: () => void;
|
|
11
|
+
testId?: string;
|
|
12
|
+
class?: ClassValue;
|
|
13
|
+
}
|
|
14
|
+
declare const RoleCard: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type RoleCard = ReturnType<typeof RoleCard>;
|
|
16
|
+
export default RoleCard;
|