@tidecloak/ui-framework 0.0.1

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.
Files changed (48) hide show
  1. package/README.md +377 -0
  2. package/dist/index.d.mts +2739 -0
  3. package/dist/index.d.ts +2739 -0
  4. package/dist/index.js +12869 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +12703 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/package.json +54 -0
  9. package/src/components/common/ActionButton.tsx +234 -0
  10. package/src/components/common/EmptyState.tsx +140 -0
  11. package/src/components/common/LoadingSkeleton.tsx +121 -0
  12. package/src/components/common/RefreshButton.tsx +127 -0
  13. package/src/components/common/StatusBadge.tsx +177 -0
  14. package/src/components/common/index.ts +31 -0
  15. package/src/components/data-table/DataTable.tsx +201 -0
  16. package/src/components/data-table/PaginatedTable.tsx +247 -0
  17. package/src/components/data-table/index.ts +2 -0
  18. package/src/components/dialogs/CollapsibleSection.tsx +184 -0
  19. package/src/components/dialogs/ConfirmDialog.tsx +264 -0
  20. package/src/components/dialogs/DetailDialog.tsx +228 -0
  21. package/src/components/dialogs/index.ts +3 -0
  22. package/src/components/index.ts +5 -0
  23. package/src/components/pages/base/ApprovalsPageBase.tsx +680 -0
  24. package/src/components/pages/base/LogsPageBase.tsx +581 -0
  25. package/src/components/pages/base/RolesPageBase.tsx +1470 -0
  26. package/src/components/pages/base/TemplatesPageBase.tsx +761 -0
  27. package/src/components/pages/base/UsersPageBase.tsx +843 -0
  28. package/src/components/pages/base/index.ts +58 -0
  29. package/src/components/pages/connected/ApprovalsPage.tsx +797 -0
  30. package/src/components/pages/connected/LogsPage.tsx +267 -0
  31. package/src/components/pages/connected/RolesPage.tsx +525 -0
  32. package/src/components/pages/connected/TemplatesPage.tsx +181 -0
  33. package/src/components/pages/connected/UsersPage.tsx +237 -0
  34. package/src/components/pages/connected/index.ts +36 -0
  35. package/src/components/pages/index.ts +5 -0
  36. package/src/components/tabs/TabsView.tsx +300 -0
  37. package/src/components/tabs/index.ts +1 -0
  38. package/src/components/ui/index.tsx +1001 -0
  39. package/src/hooks/index.ts +3 -0
  40. package/src/hooks/useAutoRefresh.ts +119 -0
  41. package/src/hooks/usePagination.ts +152 -0
  42. package/src/hooks/useSelection.ts +81 -0
  43. package/src/index.ts +256 -0
  44. package/src/theme.ts +185 -0
  45. package/src/tide/index.ts +19 -0
  46. package/src/tide/tidePolicy.ts +270 -0
  47. package/src/types/index.ts +484 -0
  48. package/src/utils/index.ts +121 -0
@@ -0,0 +1,843 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Users, Pencil, Search, Shield, User as UserIcon, Plus, Trash2, X, Link, Unlink, Copy, Check, AlertCircle } from "lucide-react";
3
+ import { useAutoRefresh } from "../../../hooks/useAutoRefresh";
4
+ import { RefreshButton } from "../../common/RefreshButton";
5
+ import { StatusBadge } from "../../common/StatusBadge";
6
+ import { LoadingSkeleton } from "../../common/LoadingSkeleton";
7
+ import { EmptyState } from "../../common/EmptyState";
8
+ import { DataTable } from "../../data-table/DataTable";
9
+ import { ConfirmDialog } from "../../dialogs/ConfirmDialog";
10
+ import { defaultComponents } from "../../ui";
11
+ import type { BaseDataItem, ColumnDef, ToastConfig } from "../../../types";
12
+
13
+ export interface UserItem extends BaseDataItem {
14
+ username?: string;
15
+ firstName: string;
16
+ lastName: string;
17
+ email: string;
18
+ role: string | string[];
19
+ enabled: boolean;
20
+ linked?: boolean;
21
+ isAdmin?: boolean;
22
+ }
23
+
24
+ export interface RoleItem {
25
+ id: string;
26
+ name: string;
27
+ description?: string;
28
+ }
29
+
30
+ export interface UserFormData {
31
+ id: string;
32
+ firstName: string;
33
+ lastName: string;
34
+ email: string;
35
+ assignedRoles: string[];
36
+ }
37
+
38
+ export interface CreateUserFormData {
39
+ username: string;
40
+ firstName: string;
41
+ lastName: string;
42
+ email: string;
43
+ }
44
+
45
+ export interface UsersPageBaseProps<T extends UserItem = UserItem> {
46
+ /** Page title */
47
+ title?: string;
48
+ /** Page description */
49
+ description?: string;
50
+ /** Whether the API is ready to be called (defaults to true for backwards compatibility) */
51
+ isReady?: boolean;
52
+ /** Data fetcher */
53
+ fetchUsers: () => Promise<T[]>;
54
+ /** Fetch available roles */
55
+ fetchRoles: () => Promise<{ roles: RoleItem[] }>;
56
+ /** Create user handler */
57
+ onCreate: (data: CreateUserFormData) => Promise<void>;
58
+ /** Update profile handler */
59
+ onUpdateProfile: (data: { id: string; firstName: string; lastName: string; email: string }) => Promise<void>;
60
+ /** Update roles handler */
61
+ onUpdateRoles: (data: { id: string; rolesToAdd?: string[]; rolesToRemove?: string[] }) => Promise<void>;
62
+ /** Delete user handler */
63
+ onDelete: (userId: string) => Promise<void>;
64
+ /** Set user enabled/disabled */
65
+ onSetEnabled?: (userId: string, enabled: boolean) => Promise<void>;
66
+ /** Get Tide link URL */
67
+ getTideLinkUrl?: (userId: string) => Promise<{ linkUrl: string }>;
68
+ /** Check if user can create more users */
69
+ checkUserLimit?: () => Promise<{ allowed: boolean; current: number; limit: number; tierName?: string }>;
70
+ /** License info for over-limit warnings */
71
+ licenseInfo?: {
72
+ overLimit?: {
73
+ users: {
74
+ isOverLimit: boolean;
75
+ enabled: number;
76
+ limit: number;
77
+ overBy: number;
78
+ };
79
+ };
80
+ };
81
+ /** Toast handler */
82
+ toast?: (config: ToastConfig) => void;
83
+ /** Auto-refresh interval */
84
+ refreshInterval?: number;
85
+ /** Query invalidation */
86
+ invalidateQueries?: (queryKeys: string[]) => void;
87
+ /** Query keys */
88
+ queryKeys?: string[];
89
+ /** Custom columns */
90
+ columns?: ColumnDef<T>[];
91
+ /** Custom components */
92
+ components?: {
93
+ Card?: React.ComponentType<any>;
94
+ CardContent?: React.ComponentType<any>;
95
+ Button?: React.ComponentType<any>;
96
+ Badge?: React.ComponentType<any>;
97
+ Skeleton?: React.ComponentType<any>;
98
+ Input?: React.ComponentType<any>;
99
+ Label?: React.ComponentType<any>;
100
+ Switch?: React.ComponentType<any>;
101
+ ScrollArea?: React.ComponentType<any>;
102
+ Table?: React.ComponentType<any>;
103
+ TableHeader?: React.ComponentType<any>;
104
+ TableBody?: React.ComponentType<any>;
105
+ TableRow?: React.ComponentType<any>;
106
+ TableHead?: React.ComponentType<any>;
107
+ TableCell?: React.ComponentType<any>;
108
+ Dialog?: React.ComponentType<any>;
109
+ DialogContent?: React.ComponentType<any>;
110
+ DialogHeader?: React.ComponentType<any>;
111
+ DialogTitle?: React.ComponentType<any>;
112
+ DialogDescription?: React.ComponentType<any>;
113
+ DialogFooter?: React.ComponentType<any>;
114
+ AlertDialog?: React.ComponentType<any>;
115
+ AlertDialogContent?: React.ComponentType<any>;
116
+ AlertDialogHeader?: React.ComponentType<any>;
117
+ AlertDialogTitle?: React.ComponentType<any>;
118
+ AlertDialogDescription?: React.ComponentType<any>;
119
+ AlertDialogFooter?: React.ComponentType<any>;
120
+ AlertDialogAction?: React.ComponentType<any>;
121
+ AlertDialogCancel?: React.ComponentType<any>;
122
+ Alert?: React.ComponentType<any>;
123
+ AlertDescription?: React.ComponentType<any>;
124
+ UpgradeBanner?: React.ComponentType<any>;
125
+ };
126
+ /** Additional class name */
127
+ className?: string;
128
+ }
129
+
130
+ // ============================================================================
131
+ // MAIN COMPONENT
132
+ // ============================================================================
133
+
134
+ /**
135
+ * UsersPage - Generic user management page
136
+ *
137
+ * @example
138
+ * ```tsx
139
+ * <UsersPage
140
+ * fetchUsers={api.admin.users.list}
141
+ * fetchRoles={() => api.admin.roles.listAll()}
142
+ * onCreate={api.admin.users.add}
143
+ * onUpdateProfile={api.admin.users.updateProfile}
144
+ * onUpdateRoles={api.admin.users.updateRoles}
145
+ * onDelete={api.admin.users.delete}
146
+ * onSetEnabled={(id, enabled) => api.admin.users.setEnabled(id, enabled)}
147
+ * toast={toast}
148
+ * />
149
+ * ```
150
+ */
151
+ export function UsersPageBase<T extends UserItem = UserItem>({
152
+ title = "Manage Users",
153
+ description = "Manage user accounts, roles, and permissions",
154
+ isReady = true,
155
+ fetchUsers,
156
+ fetchRoles,
157
+ onCreate,
158
+ onUpdateProfile,
159
+ onUpdateRoles,
160
+ onDelete,
161
+ onSetEnabled,
162
+ getTideLinkUrl,
163
+ checkUserLimit,
164
+ licenseInfo,
165
+ toast,
166
+ refreshInterval = 15,
167
+ invalidateQueries,
168
+ queryKeys,
169
+ columns: customColumns,
170
+ components = {},
171
+ className,
172
+ }: UsersPageBaseProps<T>) {
173
+ // State
174
+ const [users, setUsers] = useState<T[]>([]);
175
+ const [allRoles, setAllRoles] = useState<RoleItem[]>([]);
176
+ const [isLoading, setIsLoading] = useState(false);
177
+ const [search, setSearch] = useState("");
178
+ const [editingUser, setEditingUser] = useState<T | null>(null);
179
+ const [creatingUser, setCreatingUser] = useState(false);
180
+ const [deletingUser, setDeletingUser] = useState<T | null>(null);
181
+ const [copyStatus, setCopyStatus] = useState("");
182
+ const [userLimit, setUserLimit] = useState<{ allowed: boolean; current: number; limit: number; tierName?: string } | null>(null);
183
+ const [isSubmitting, setIsSubmitting] = useState(false);
184
+
185
+ const [formData, setFormData] = useState<UserFormData>({
186
+ id: "",
187
+ firstName: "",
188
+ lastName: "",
189
+ email: "",
190
+ assignedRoles: [],
191
+ });
192
+ const [initialRoles, setInitialRoles] = useState<string[]>([]);
193
+
194
+ const [createFormData, setCreateFormData] = useState<CreateUserFormData>({
195
+ username: "",
196
+ firstName: "",
197
+ lastName: "",
198
+ email: "",
199
+ });
200
+
201
+ // Components (use defaultComponents from ui if not provided)
202
+ const Card = components.Card || defaultComponents.Card;
203
+ const CardContent = components.CardContent || defaultComponents.CardContent;
204
+ const Button = components.Button || defaultComponents.Button;
205
+ const Badge = components.Badge || defaultComponents.Badge;
206
+ const Input = components.Input || defaultComponents.Input;
207
+ const Label = components.Label || defaultComponents.Label;
208
+ const Switch = components.Switch || defaultComponents.Switch;
209
+ const ScrollArea = components.ScrollArea || defaultComponents.ScrollArea;
210
+ const Dialog = components.Dialog || defaultComponents.Dialog;
211
+ const DialogContent = components.DialogContent || defaultComponents.DialogContent;
212
+ const DialogHeader = components.DialogHeader || defaultComponents.DialogHeader;
213
+ const DialogTitle = components.DialogTitle || defaultComponents.DialogTitle;
214
+ const DialogDescription = components.DialogDescription || defaultComponents.DialogDescription;
215
+ const DialogFooter = components.DialogFooter || defaultComponents.DialogFooter;
216
+ const Alert = components.Alert || defaultComponents.Alert;
217
+ const AlertDescription = components.AlertDescription || defaultComponents.AlertDescription;
218
+
219
+ // Fetch data
220
+ const loadData = async () => {
221
+ setIsLoading(true);
222
+ try {
223
+ const [usersResult, rolesResult, limitResult] = await Promise.all([
224
+ fetchUsers(),
225
+ fetchRoles(),
226
+ checkUserLimit?.(),
227
+ ]);
228
+ setUsers(Array.isArray(usersResult) ? usersResult : (usersResult as any).users || []);
229
+ setAllRoles(Array.isArray(rolesResult) ? rolesResult : (rolesResult as any).roles || []);
230
+ if (limitResult) setUserLimit(limitResult);
231
+ if (invalidateQueries && queryKeys) {
232
+ invalidateQueries(queryKeys);
233
+ }
234
+ } catch (error) {
235
+ console.error("Error fetching users:", error);
236
+ toast?.({
237
+ title: "Failed to fetch users",
238
+ description: error instanceof Error ? error.message : "Unknown error",
239
+ variant: "destructive",
240
+ });
241
+ } finally {
242
+ setIsLoading(false);
243
+ }
244
+ };
245
+
246
+ // Auto refresh (blocked until API is ready)
247
+ const { secondsRemaining, refreshNow } = useAutoRefresh({
248
+ intervalSeconds: refreshInterval,
249
+ refresh: loadData,
250
+ isBlocked: !isReady || isLoading || isSubmitting,
251
+ });
252
+
253
+ // Initial fetch (only when API is ready)
254
+ useEffect(() => {
255
+ if (isReady) {
256
+ void loadData();
257
+ }
258
+ }, [isReady]);
259
+
260
+ // Handlers
261
+ const handleEdit = (user: T) => {
262
+ const userRoles = Array.isArray(user.role) ? user.role : user.role ? [user.role] : [];
263
+ setEditingUser(user);
264
+ setInitialRoles(userRoles);
265
+ setFormData({
266
+ id: user.id,
267
+ firstName: user.firstName,
268
+ lastName: user.lastName,
269
+ email: user.email,
270
+ assignedRoles: userRoles,
271
+ });
272
+ };
273
+
274
+ const handleSubmit = async (e: React.FormEvent) => {
275
+ e.preventDefault();
276
+ if (!editingUser) return;
277
+
278
+ const profileChanged =
279
+ editingUser.firstName !== formData.firstName ||
280
+ editingUser.lastName !== formData.lastName ||
281
+ editingUser.email !== formData.email;
282
+
283
+ const rolesToAdd = formData.assignedRoles.filter((role) => !initialRoles.includes(role));
284
+ const rolesToRemove = initialRoles.filter((role) => !formData.assignedRoles.includes(role));
285
+ const rolesChanged = rolesToAdd.length > 0 || rolesToRemove.length > 0;
286
+
287
+ setIsSubmitting(true);
288
+ try {
289
+ if (profileChanged) {
290
+ await onUpdateProfile({
291
+ id: formData.id,
292
+ firstName: formData.firstName,
293
+ lastName: formData.lastName,
294
+ email: formData.email,
295
+ });
296
+ }
297
+
298
+ if (rolesChanged) {
299
+ await onUpdateRoles({
300
+ id: formData.id,
301
+ rolesToAdd: rolesToAdd.length > 0 ? rolesToAdd : undefined,
302
+ rolesToRemove: rolesToRemove.length > 0 ? rolesToRemove : undefined,
303
+ });
304
+ }
305
+
306
+ setEditingUser(null);
307
+ toast?.({ title: "User updated successfully" });
308
+ await loadData();
309
+ } catch (error) {
310
+ toast?.({
311
+ title: "Failed to update user",
312
+ description: error instanceof Error ? error.message : "Unknown error",
313
+ variant: "destructive",
314
+ });
315
+ } finally {
316
+ setIsSubmitting(false);
317
+ }
318
+ };
319
+
320
+ const handleCreateSubmit = async (e: React.FormEvent) => {
321
+ e.preventDefault();
322
+ setIsSubmitting(true);
323
+ try {
324
+ await onCreate(createFormData);
325
+ setCreatingUser(false);
326
+ setCreateFormData({ username: "", firstName: "", lastName: "", email: "" });
327
+ toast?.({ title: "User created successfully" });
328
+ await loadData();
329
+ } catch (error) {
330
+ toast?.({
331
+ title: "Failed to create user",
332
+ description: error instanceof Error ? error.message : "Unknown error",
333
+ variant: "destructive",
334
+ });
335
+ } finally {
336
+ setIsSubmitting(false);
337
+ }
338
+ };
339
+
340
+ const handleDeleteConfirm = async () => {
341
+ if (!deletingUser) return;
342
+ setIsSubmitting(true);
343
+ try {
344
+ await onDelete(deletingUser.id);
345
+ setDeletingUser(null);
346
+ setEditingUser(null);
347
+ toast?.({ title: "User deleted successfully" });
348
+ await loadData();
349
+ } catch (error) {
350
+ toast?.({
351
+ title: "Failed to delete user",
352
+ description: error instanceof Error ? error.message : "Unknown error",
353
+ variant: "destructive",
354
+ });
355
+ } finally {
356
+ setIsSubmitting(false);
357
+ }
358
+ };
359
+
360
+ const handleSetEnabled = async (userId: string, enabled: boolean) => {
361
+ if (!onSetEnabled) return;
362
+ try {
363
+ await onSetEnabled(userId, enabled);
364
+ toast?.({ title: enabled ? "User enabled" : "User disabled" });
365
+ await loadData();
366
+ } catch (error) {
367
+ toast?.({
368
+ title: "Failed to update user status",
369
+ description: error instanceof Error ? error.message : "Unknown error",
370
+ variant: "destructive",
371
+ });
372
+ }
373
+ };
374
+
375
+ const handleCopyTideLink = async () => {
376
+ if (!editingUser || !getTideLinkUrl) return;
377
+ try {
378
+ const response = await getTideLinkUrl(editingUser.id);
379
+ await navigator.clipboard.writeText(response.linkUrl);
380
+ setCopyStatus("Copied!");
381
+ setTimeout(() => setCopyStatus(""), 2000);
382
+ } catch {
383
+ setCopyStatus("Failed to copy");
384
+ setTimeout(() => setCopyStatus(""), 2000);
385
+ }
386
+ };
387
+
388
+ const assignRole = (roleName: string) => {
389
+ setFormData((prev) => ({
390
+ ...prev,
391
+ assignedRoles: [...prev.assignedRoles, roleName],
392
+ }));
393
+ };
394
+
395
+ const unassignRole = (roleName: string) => {
396
+ setFormData((prev) => ({
397
+ ...prev,
398
+ assignedRoles: prev.assignedRoles.filter((r) => r !== roleName),
399
+ }));
400
+ };
401
+
402
+ const availableRoles = allRoles
403
+ .map((role) => role.name)
404
+ .filter((roleName) => !formData.assignedRoles.includes(roleName));
405
+
406
+ const filteredUsers = users.filter(
407
+ (user) =>
408
+ user.username?.toLowerCase().includes(search.toLowerCase()) ||
409
+ user.email.toLowerCase().includes(search.toLowerCase()) ||
410
+ user.firstName.toLowerCase().includes(search.toLowerCase()) ||
411
+ user.lastName.toLowerCase().includes(search.toLowerCase())
412
+ );
413
+
414
+ // Default columns
415
+ const defaultColumns: ColumnDef<T>[] = [
416
+ {
417
+ key: "user",
418
+ header: "User",
419
+ cell: (user) => {
420
+ const userRoles = Array.isArray(user.role) ? user.role : user.role ? [user.role] : [];
421
+ const isAdmin = userRoles.some((r) => r.toLowerCase().includes("admin"));
422
+ return (
423
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
424
+ <div style={{ display: "flex", height: "2.25rem", width: "2.25rem", alignItems: "center", justifyContent: "center", borderRadius: "9999px", backgroundColor: "rgba(59, 130, 246, 0.1)" }}>
425
+ {isAdmin ? (
426
+ <Shield style={{ height: "1rem", width: "1rem", color: "#3b82f6" }} />
427
+ ) : (
428
+ <UserIcon style={{ height: "1rem", width: "1rem", color: "#3b82f6" }} />
429
+ )}
430
+ </div>
431
+ <div>
432
+ <p style={{ fontWeight: 500 }}>{user.firstName} {user.lastName}</p>
433
+ <p style={{ fontSize: "0.75rem", color: "#6b7280" }}>{user.email}</p>
434
+ </div>
435
+ </div>
436
+ );
437
+ },
438
+ },
439
+ {
440
+ key: "roles",
441
+ header: "Roles",
442
+ responsive: "sm",
443
+ cell: (user) => {
444
+ const userRoles = Array.isArray(user.role) ? user.role : user.role ? [user.role] : [];
445
+ return (
446
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.25rem" }}>
447
+ {userRoles.length > 0 ? (
448
+ <>
449
+ {userRoles.slice(0, 2).map((role) => (
450
+ <Badge key={role} variant="secondary" style={{ fontSize: "0.75rem" }}>{role}</Badge>
451
+ ))}
452
+ {userRoles.length > 2 && (
453
+ <Badge variant="outline" style={{ fontSize: "0.75rem" }}>+{userRoles.length - 2}</Badge>
454
+ )}
455
+ </>
456
+ ) : (
457
+ <span style={{ fontSize: "0.75rem", color: "#6b7280" }}>No roles</span>
458
+ )}
459
+ </div>
460
+ );
461
+ },
462
+ },
463
+ {
464
+ key: "status",
465
+ header: "Account Status",
466
+ responsive: "md",
467
+ cell: (user) => (
468
+ user.linked ? (
469
+ <StatusBadge status="success" label="Linked" icon={<Link style={{ height: "0.75rem", width: "0.75rem", marginRight: "0.25rem" }} />} />
470
+ ) : (
471
+ <StatusBadge status="info" label="Not linked" icon={<Unlink style={{ height: "0.75rem", width: "0.75rem", marginRight: "0.25rem" }} />} />
472
+ )
473
+ ),
474
+ },
475
+ {
476
+ key: "access",
477
+ header: "Access",
478
+ responsive: "lg",
479
+ cell: (user) => {
480
+ const userRoles = Array.isArray(user.role) ? user.role : user.role ? [user.role] : [];
481
+ const isAdmin = userRoles.some((r) => r.toLowerCase().includes("admin"));
482
+
483
+ if (isAdmin) {
484
+ return <span style={{ fontSize: "0.75rem", color: "#6b7280" }}>Admin (cannot disable)</span>;
485
+ }
486
+
487
+ return onSetEnabled ? (
488
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
489
+ <Switch
490
+ checked={user.enabled}
491
+ onCheckedChange={(enabled: boolean) => handleSetEnabled(user.id, enabled)}
492
+ />
493
+ <span style={{ fontSize: "0.75rem", color: user.enabled ? "#16a34a" : "#6b7280" }}>
494
+ {user.enabled ? "Enabled" : "Disabled"}
495
+ </span>
496
+ </div>
497
+ ) : (
498
+ <span style={{ fontSize: "0.75rem", color: user.enabled ? "#16a34a" : "#6b7280" }}>
499
+ {user.enabled ? "Enabled" : "Disabled"}
500
+ </span>
501
+ );
502
+ },
503
+ },
504
+ {
505
+ key: "actions",
506
+ header: "Actions",
507
+ align: "right",
508
+ cell: (user) => (
509
+ <Button size="icon" variant="ghost" onClick={() => handleEdit(user)}>
510
+ <Pencil style={{ height: "1rem", width: "1rem" }} />
511
+ </Button>
512
+ ),
513
+ },
514
+ ];
515
+
516
+ const columns = customColumns || defaultColumns;
517
+
518
+ return (
519
+ <div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.5rem" }} className={className}>
520
+ {/* Header */}
521
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "1rem" }}>
522
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
523
+ <h1 style={{ fontSize: "1.5rem", fontWeight: 600, display: "flex", alignItems: "center", gap: "0.5rem", margin: 0 }}>
524
+ <Users style={{ height: "1.5rem", width: "1.5rem" }} />
525
+ {title}
526
+ </h1>
527
+ <p style={{ fontSize: "0.875rem", color: "#6b7280", margin: 0 }}>{description}</p>
528
+ </div>
529
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
530
+ <RefreshButton
531
+ onClick={() => void refreshNow()}
532
+ isRefreshing={isLoading}
533
+ secondsRemaining={secondsRemaining}
534
+ title="Refresh now"
535
+ ButtonComponent={components.Button}
536
+ />
537
+ <Button
538
+ onClick={() => setCreatingUser(true)}
539
+ disabled={userLimit ? !userLimit.allowed : false}
540
+ title={
541
+ userLimit && !userLimit.allowed
542
+ ? `User limit reached (${userLimit.current}/${userLimit.limit})`
543
+ : "Add User"
544
+ }
545
+ >
546
+ <Plus style={{ height: "1rem", width: "1rem", marginRight: "0.5rem" }} />
547
+ Add User
548
+ </Button>
549
+ </div>
550
+ </div>
551
+
552
+ {/* Limit Warning */}
553
+ {userLimit && !userLimit.allowed && components.UpgradeBanner && (
554
+ <components.UpgradeBanner
555
+ message={`User limit reached (${userLimit.current}/${userLimit.limit}) on the ${userLimit.tierName} plan.`}
556
+ current={userLimit.current}
557
+ limit={userLimit.limit}
558
+ tierName={userLimit.tierName}
559
+ />
560
+ )}
561
+
562
+ {/* Over Limit Alert */}
563
+ {licenseInfo?.overLimit?.users.isOverLimit && (
564
+ <Alert style={{ backgroundColor: "#fef2f2", borderColor: "#fecaca" }}>
565
+ <AlertCircle style={{ height: "1rem", width: "1rem", color: "#dc2626" }} />
566
+ <AlertDescription style={{ color: "#991b1b" }}>
567
+ <strong>User limit exceeded.</strong> You have {licenseInfo.overLimit.users.enabled} enabled users but your plan allows {licenseInfo.overLimit.users.limit}.
568
+ Please disable {licenseInfo.overLimit.users.overBy} user(s) or upgrade your plan.
569
+ </AlertDescription>
570
+ </Alert>
571
+ )}
572
+
573
+ {/* Users Table */}
574
+ <Card>
575
+ <div style={{ padding: "1rem", borderBottom: "1px solid #e5e7eb" }}>
576
+ <div style={{ position: "relative", maxWidth: "20rem" }}>
577
+ <Search style={{ position: "absolute", left: "0.75rem", top: "50%", transform: "translateY(-50%)", height: "1rem", width: "1rem", color: "#9ca3af" }} />
578
+ <Input
579
+ placeholder="Search users..."
580
+ value={search}
581
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
582
+ style={{ paddingLeft: "2.25rem" }}
583
+ />
584
+ </div>
585
+ </div>
586
+ <CardContent style={{ padding: 0 }}>
587
+ {isLoading && users.length === 0 ? (
588
+ <LoadingSkeleton rows={3} type="table" SkeletonComponent={components.Skeleton} />
589
+ ) : filteredUsers.length > 0 ? (
590
+ <DataTable
591
+ data={filteredUsers}
592
+ columns={columns}
593
+ components={{
594
+ Table: components.Table,
595
+ TableHeader: components.TableHeader,
596
+ TableBody: components.TableBody,
597
+ TableRow: components.TableRow,
598
+ TableHead: components.TableHead,
599
+ TableCell: components.TableCell,
600
+ Skeleton: components.Skeleton,
601
+ }}
602
+ />
603
+ ) : (
604
+ <EmptyState
605
+ icon={<Users style={{ height: "3rem", width: "3rem", color: "#6b7280" }} />}
606
+ title="No users found"
607
+ description={search ? "Try a different search term" : "No users have been created yet"}
608
+ />
609
+ )}
610
+ </CardContent>
611
+ </Card>
612
+
613
+ {/* Edit User Dialog */}
614
+ <Dialog open={!!editingUser} onOpenChange={(open: boolean) => !open && setEditingUser(null)}>
615
+ <DialogContent style={{ maxWidth: "32rem" }}>
616
+ <DialogHeader>
617
+ <DialogTitle>Edit User</DialogTitle>
618
+ <DialogDescription>Update user details and manage role assignments</DialogDescription>
619
+ </DialogHeader>
620
+ <form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
621
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
622
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
623
+ <Label>First Name</Label>
624
+ <Input
625
+ value={formData.firstName}
626
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, firstName: e.target.value })}
627
+ required
628
+ />
629
+ </div>
630
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
631
+ <Label>Last Name</Label>
632
+ <Input
633
+ value={formData.lastName}
634
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, lastName: e.target.value })}
635
+ required
636
+ />
637
+ </div>
638
+ </div>
639
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
640
+ <Label>Email</Label>
641
+ <Input
642
+ type="email"
643
+ value={formData.email}
644
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, email: e.target.value })}
645
+ required
646
+ />
647
+ </div>
648
+
649
+ {/* Role Manager */}
650
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
651
+ <Label>Manage Roles</Label>
652
+ {!editingUser?.linked && (
653
+ <p style={{ fontSize: "0.875rem", color: "#d97706" }}>
654
+ User must be linked before roles can be assigned.
655
+ </p>
656
+ )}
657
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem", opacity: !editingUser?.linked ? 0.5 : 1, pointerEvents: !editingUser?.linked ? "none" : "auto" }}>
658
+ {/* Assigned Roles */}
659
+ <div style={{ border: "1px solid #e5e7eb", borderRadius: "0.375rem" }}>
660
+ <div style={{ padding: "0.5rem", backgroundColor: "#f9fafb", borderBottom: "1px solid #e5e7eb" }}>
661
+ <h4 style={{ fontSize: "0.875rem", fontWeight: 500 }}>Assigned Roles</h4>
662
+ </div>
663
+ <ScrollArea style={{ height: "8rem" }}>
664
+ <div style={{ padding: "0.5rem", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
665
+ {formData.assignedRoles.length > 0 ? (
666
+ formData.assignedRoles.map((roleName) => (
667
+ <div key={roleName} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.5rem", borderRadius: "0.375rem", backgroundColor: "#f3f4f6", fontSize: "0.875rem" }}>
668
+ <span>{roleName}</span>
669
+ <Button
670
+ type="button"
671
+ size="icon"
672
+ variant="ghost"
673
+ style={{ height: "1.5rem", width: "1.5rem" }}
674
+ onClick={() => unassignRole(roleName)}
675
+ disabled={!editingUser?.linked}
676
+ >
677
+ <X style={{ height: "0.75rem", width: "0.75rem" }} />
678
+ </Button>
679
+ </div>
680
+ ))
681
+ ) : (
682
+ <p style={{ fontSize: "0.875rem", color: "#6b7280", textAlign: "center", padding: "1rem 0" }}>No roles assigned</p>
683
+ )}
684
+ </div>
685
+ </ScrollArea>
686
+ </div>
687
+
688
+ {/* Available Roles */}
689
+ <div style={{ border: "1px solid #e5e7eb", borderRadius: "0.375rem" }}>
690
+ <div style={{ padding: "0.5rem", backgroundColor: "#f9fafb", borderBottom: "1px solid #e5e7eb" }}>
691
+ <h4 style={{ fontSize: "0.875rem", fontWeight: 500 }}>Available Roles</h4>
692
+ </div>
693
+ <ScrollArea style={{ height: "8rem" }}>
694
+ <div style={{ padding: "0.5rem", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
695
+ {availableRoles.length > 0 ? (
696
+ availableRoles.map((roleName) => (
697
+ <div
698
+ key={roleName}
699
+ style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.5rem", borderRadius: "0.375rem", fontSize: "0.875rem", cursor: "pointer" }}
700
+ onClick={() => editingUser?.linked && assignRole(roleName)}
701
+ >
702
+ <span>{roleName}</span>
703
+ <Button
704
+ type="button"
705
+ size="icon"
706
+ variant="ghost"
707
+ style={{ height: "1.5rem", width: "1.5rem" }}
708
+ onClick={(e: React.MouseEvent) => { e.stopPropagation(); assignRole(roleName); }}
709
+ disabled={!editingUser?.linked}
710
+ >
711
+ <Plus style={{ height: "0.75rem", width: "0.75rem" }} />
712
+ </Button>
713
+ </div>
714
+ ))
715
+ ) : (
716
+ <p style={{ fontSize: "0.875rem", color: "#6b7280", textAlign: "center", padding: "1rem 0" }}>All roles assigned</p>
717
+ )}
718
+ </div>
719
+ </ScrollArea>
720
+ </div>
721
+ </div>
722
+ </div>
723
+
724
+ {/* Link Tide Account */}
725
+ {!editingUser?.linked && getTideLinkUrl && (
726
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
727
+ <Label>Link Tide Account</Label>
728
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
729
+ <Button type="button" variant="outline" onClick={handleCopyTideLink}>
730
+ <Copy style={{ height: "1rem", width: "1rem", marginRight: "0.5rem" }} />
731
+ Copy Tide Link
732
+ </Button>
733
+ {copyStatus && (
734
+ <span style={{ fontSize: "0.875rem", color: "#6b7280", display: "flex", alignItems: "center", gap: "0.25rem" }}>
735
+ <Check style={{ height: "1rem", width: "1rem", color: "#16a34a" }} />
736
+ {copyStatus}
737
+ </span>
738
+ )}
739
+ </div>
740
+ </div>
741
+ )}
742
+
743
+ <DialogFooter style={{ display: "flex", justifyContent: "space-between" }}>
744
+ <Button type="button" variant="destructive" onClick={() => editingUser && setDeletingUser(editingUser)}>
745
+ <Trash2 style={{ height: "1rem", width: "1rem", marginRight: "0.5rem" }} />
746
+ Delete
747
+ </Button>
748
+ <div style={{ display: "flex", gap: "0.5rem" }}>
749
+ <Button type="button" variant="outline" onClick={() => setEditingUser(null)}>Cancel</Button>
750
+ <Button type="submit" disabled={isSubmitting}>
751
+ {isSubmitting ? "Saving..." : "Save Changes"}
752
+ </Button>
753
+ </div>
754
+ </DialogFooter>
755
+ </form>
756
+ </DialogContent>
757
+ </Dialog>
758
+
759
+ {/* Create User Dialog */}
760
+ <Dialog open={creatingUser} onOpenChange={(open: boolean) => !open && setCreatingUser(false)}>
761
+ <DialogContent style={{ maxWidth: "28rem" }}>
762
+ <DialogHeader>
763
+ <DialogTitle>Add New User</DialogTitle>
764
+ <DialogDescription>Create a new user account</DialogDescription>
765
+ </DialogHeader>
766
+ <form onSubmit={handleCreateSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
767
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
768
+ <Label htmlFor="username">Username</Label>
769
+ <Input
770
+ id="username"
771
+ value={createFormData.username}
772
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCreateFormData({ ...createFormData, username: e.target.value })}
773
+ placeholder="johndoe"
774
+ required
775
+ />
776
+ </div>
777
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
778
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
779
+ <Label htmlFor="firstName">First Name</Label>
780
+ <Input
781
+ id="firstName"
782
+ value={createFormData.firstName}
783
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCreateFormData({ ...createFormData, firstName: e.target.value })}
784
+ placeholder="John"
785
+ required
786
+ />
787
+ </div>
788
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
789
+ <Label htmlFor="lastName">Last Name</Label>
790
+ <Input
791
+ id="lastName"
792
+ value={createFormData.lastName}
793
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCreateFormData({ ...createFormData, lastName: e.target.value })}
794
+ placeholder="Doe"
795
+ required
796
+ />
797
+ </div>
798
+ </div>
799
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
800
+ <Label htmlFor="email">Email</Label>
801
+ <Input
802
+ id="email"
803
+ type="email"
804
+ value={createFormData.email}
805
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCreateFormData({ ...createFormData, email: e.target.value })}
806
+ placeholder="john@example.com"
807
+ required
808
+ />
809
+ </div>
810
+ <DialogFooter>
811
+ <Button type="button" variant="outline" onClick={() => setCreatingUser(false)}>Cancel</Button>
812
+ <Button type="submit" disabled={isSubmitting}>
813
+ {isSubmitting ? "Creating..." : "Create User"}
814
+ </Button>
815
+ </DialogFooter>
816
+ </form>
817
+ </DialogContent>
818
+ </Dialog>
819
+
820
+ {/* Delete Confirmation */}
821
+ <ConfirmDialog
822
+ open={!!deletingUser}
823
+ onClose={() => setDeletingUser(null)}
824
+ onConfirm={handleDeleteConfirm}
825
+ title="Delete User"
826
+ description={`Are you sure you want to delete ${deletingUser?.firstName} ${deletingUser?.lastName}? This action cannot be undone.`}
827
+ confirmLabel="Delete"
828
+ confirmVariant="destructive"
829
+ isLoading={isSubmitting}
830
+ components={{
831
+ AlertDialog: components.AlertDialog,
832
+ AlertDialogContent: components.AlertDialogContent,
833
+ AlertDialogHeader: components.AlertDialogHeader,
834
+ AlertDialogTitle: components.AlertDialogTitle,
835
+ AlertDialogDescription: components.AlertDialogDescription,
836
+ AlertDialogFooter: components.AlertDialogFooter,
837
+ AlertDialogAction: components.AlertDialogAction,
838
+ AlertDialogCancel: components.AlertDialogCancel,
839
+ }}
840
+ />
841
+ </div>
842
+ );
843
+ }