@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.
- package/README.md +377 -0
- package/dist/index.d.mts +2739 -0
- package/dist/index.d.ts +2739 -0
- package/dist/index.js +12869 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +12703 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/src/components/common/ActionButton.tsx +234 -0
- package/src/components/common/EmptyState.tsx +140 -0
- package/src/components/common/LoadingSkeleton.tsx +121 -0
- package/src/components/common/RefreshButton.tsx +127 -0
- package/src/components/common/StatusBadge.tsx +177 -0
- package/src/components/common/index.ts +31 -0
- package/src/components/data-table/DataTable.tsx +201 -0
- package/src/components/data-table/PaginatedTable.tsx +247 -0
- package/src/components/data-table/index.ts +2 -0
- package/src/components/dialogs/CollapsibleSection.tsx +184 -0
- package/src/components/dialogs/ConfirmDialog.tsx +264 -0
- package/src/components/dialogs/DetailDialog.tsx +228 -0
- package/src/components/dialogs/index.ts +3 -0
- package/src/components/index.ts +5 -0
- package/src/components/pages/base/ApprovalsPageBase.tsx +680 -0
- package/src/components/pages/base/LogsPageBase.tsx +581 -0
- package/src/components/pages/base/RolesPageBase.tsx +1470 -0
- package/src/components/pages/base/TemplatesPageBase.tsx +761 -0
- package/src/components/pages/base/UsersPageBase.tsx +843 -0
- package/src/components/pages/base/index.ts +58 -0
- package/src/components/pages/connected/ApprovalsPage.tsx +797 -0
- package/src/components/pages/connected/LogsPage.tsx +267 -0
- package/src/components/pages/connected/RolesPage.tsx +525 -0
- package/src/components/pages/connected/TemplatesPage.tsx +181 -0
- package/src/components/pages/connected/UsersPage.tsx +237 -0
- package/src/components/pages/connected/index.ts +36 -0
- package/src/components/pages/index.ts +5 -0
- package/src/components/tabs/TabsView.tsx +300 -0
- package/src/components/tabs/index.ts +1 -0
- package/src/components/ui/index.tsx +1001 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useAutoRefresh.ts +119 -0
- package/src/hooks/usePagination.ts +152 -0
- package/src/hooks/useSelection.ts +81 -0
- package/src/index.ts +256 -0
- package/src/theme.ts +185 -0
- package/src/tide/index.ts +19 -0
- package/src/tide/tidePolicy.ts +270 -0
- package/src/types/index.ts +484 -0
- 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
|
+
}
|