@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,761 @@
1
+ import React, { useState } from "react";
2
+ import { FileCode, Pencil, Plus, Trash2, Search, Code, Variable } from "lucide-react";
3
+ import { useAutoRefresh } from "../../../hooks/useAutoRefresh";
4
+ import { RefreshButton } from "../../common/RefreshButton";
5
+ import { LoadingSkeleton } from "../../common/LoadingSkeleton";
6
+ import { EmptyState } from "../../common/EmptyState";
7
+ import { DataTable } from "../../data-table/DataTable";
8
+ import { ConfirmDialog } from "../../dialogs/ConfirmDialog";
9
+ import { defaultComponents } from "../../ui";
10
+ import type { BaseDataItem, ColumnDef, ToastConfig, ParameterDef, FormFieldType } from "../../../types";
11
+
12
+ export interface TemplateParameter {
13
+ name: string;
14
+ type: "string" | "number" | "boolean" | "select";
15
+ helpText: string;
16
+ required: boolean;
17
+ defaultValue?: any;
18
+ options?: string[];
19
+ }
20
+
21
+ export interface PolicyTemplateItem extends BaseDataItem {
22
+ name: string;
23
+ description: string;
24
+ csCode: string;
25
+ parameters: TemplateParameter[];
26
+ createdBy: string;
27
+ }
28
+
29
+ export interface TemplateFormData {
30
+ name: string;
31
+ description: string;
32
+ csCode: string;
33
+ parameters: TemplateParameter[];
34
+ }
35
+
36
+ export interface TemplatesPageBaseProps<T extends PolicyTemplateItem = PolicyTemplateItem> {
37
+ /** Page title */
38
+ title?: string;
39
+ /** Page description */
40
+ description?: string;
41
+ /** Help text shown below description */
42
+ helpText?: React.ReactNode;
43
+ /** Data fetcher */
44
+ fetchData: () => Promise<{ templates: T[] }>;
45
+ /** Create handler */
46
+ onCreate: (data: TemplateFormData) => Promise<void>;
47
+ /** Update handler */
48
+ onUpdate: (id: string, data: Partial<TemplateFormData>) => Promise<void>;
49
+ /** Delete handler */
50
+ onDelete: (id: string) => Promise<void>;
51
+ /** Check if user can edit templates */
52
+ canEdit?: boolean;
53
+ /** Default code for new templates */
54
+ defaultCode?: string;
55
+ /** Code editor language */
56
+ codeLanguage?: string;
57
+ /** Toast notification handler */
58
+ toast?: (config: ToastConfig) => void;
59
+ /** Auto-refresh interval in seconds */
60
+ refreshInterval?: number;
61
+ /** Query invalidation handler */
62
+ invalidateQueries?: (queryKeys: string[]) => void;
63
+ /** Query keys */
64
+ queryKeys?: string[];
65
+ /** Custom column definitions */
66
+ columns?: ColumnDef<T>[];
67
+ /** Empty state configuration */
68
+ emptyState?: {
69
+ icon?: React.ReactNode;
70
+ title?: string;
71
+ description?: string;
72
+ };
73
+ /** Custom components */
74
+ components?: {
75
+ Card?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
76
+ CardContent?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
77
+ Button?: React.ComponentType<any>;
78
+ Badge?: React.ComponentType<any>;
79
+ Skeleton?: React.ComponentType<{ className?: string }>;
80
+ Input?: React.ComponentType<any>;
81
+ Textarea?: React.ComponentType<any>;
82
+ Label?: React.ComponentType<any>;
83
+ Switch?: React.ComponentType<any>;
84
+ Select?: React.ComponentType<any>;
85
+ SelectTrigger?: React.ComponentType<any>;
86
+ SelectValue?: React.ComponentType<any>;
87
+ SelectContent?: React.ComponentType<any>;
88
+ SelectItem?: React.ComponentType<any>;
89
+ // Table components
90
+ Table?: React.ComponentType<any>;
91
+ TableHeader?: React.ComponentType<any>;
92
+ TableBody?: React.ComponentType<any>;
93
+ TableRow?: React.ComponentType<any>;
94
+ TableHead?: React.ComponentType<any>;
95
+ TableCell?: React.ComponentType<any>;
96
+ // Dialog components
97
+ Dialog?: React.ComponentType<any>;
98
+ DialogContent?: React.ComponentType<any>;
99
+ DialogHeader?: React.ComponentType<any>;
100
+ DialogTitle?: React.ComponentType<any>;
101
+ DialogDescription?: React.ComponentType<any>;
102
+ DialogFooter?: React.ComponentType<any>;
103
+ // Alert dialog
104
+ AlertDialog?: React.ComponentType<any>;
105
+ AlertDialogContent?: React.ComponentType<any>;
106
+ AlertDialogHeader?: React.ComponentType<any>;
107
+ AlertDialogTitle?: React.ComponentType<any>;
108
+ AlertDialogDescription?: React.ComponentType<any>;
109
+ AlertDialogFooter?: React.ComponentType<any>;
110
+ AlertDialogAction?: React.ComponentType<any>;
111
+ AlertDialogCancel?: React.ComponentType<any>;
112
+ // Code editor
113
+ CodeEditor?: React.ComponentType<{
114
+ value: string;
115
+ onChange: (value: string) => void;
116
+ language?: string;
117
+ height?: string;
118
+ }>;
119
+ };
120
+ /** Additional class name */
121
+ className?: string;
122
+ }
123
+
124
+ // ============================================================================
125
+ // DEFAULT CODE TEMPLATE
126
+ // ============================================================================
127
+ const DEFAULT_CS_CODE = `using Ork.Forseti.Sdk;
128
+ using System;
129
+ using System.Collections.Generic;
130
+ using System.Text;
131
+
132
+ /// <summary>
133
+ /// Forseti contract - start here.
134
+ /// </summary>
135
+ public class Contract : IAccessPolicy
136
+ {
137
+ // These policy parameters are filled in by the policy "params" map.
138
+ // Marking them Required ensures missing values result in deny/validation failures.
139
+ [PolicyParam(Required = true, Description = "Role required for SSH access (e.g., ssh:root)")]
140
+ public string Role { get; set; }
141
+
142
+ [PolicyParam(Required = true, Description = "Resource identifier for role checks")]
143
+ public string Resource { get; set; }
144
+
145
+ // Called for every request. Validate the request data payload and return Allow/Deny.
146
+ // For SSH signing, ctx.Data is the SSHv2 publickey auth "to-be-signed" payload.
147
+ public PolicyDecision ValidateData(DataContext ctx)
148
+ {
149
+ throw new NotImplementedException("Implement SSH challenge validation in ValidateData().");
150
+ }
151
+
152
+ // Called for explicit approvals. Validate that the approvers are allowed to approve this request.
153
+ public PolicyDecision ValidateApprovers(ApproversContext ctx)
154
+ {
155
+ throw new NotImplementedException("Implement approver validation in ValidateApprovers().");
156
+ }
157
+
158
+ // Called for execution. Validate that the executor (caller) is allowed to execute this request.
159
+ public PolicyDecision ValidateExecutor(ExecutorContext ctx)
160
+ {
161
+ throw new NotImplementedException("Implement executor validation in ValidateExecutor().");
162
+ }
163
+ }`;
164
+
165
+ const defaultFormData: TemplateFormData = {
166
+ name: "",
167
+ description: "",
168
+ csCode: DEFAULT_CS_CODE,
169
+ parameters: [],
170
+ };
171
+
172
+ // ============================================================================
173
+ // MAIN COMPONENT
174
+ // ============================================================================
175
+
176
+ /**
177
+ * TemplatesPage - Generic template management page
178
+ *
179
+ * @example
180
+ * ```tsx
181
+ * <TemplatesPage
182
+ * fetchData={api.admin.policyTemplates.list}
183
+ * onCreate={api.admin.policyTemplates.create}
184
+ * onUpdate={api.admin.policyTemplates.update}
185
+ * onDelete={api.admin.policyTemplates.delete}
186
+ * canEdit={canManageTemplates()}
187
+ * toast={toast}
188
+ * />
189
+ * ```
190
+ */
191
+ export function TemplatesPageBase<T extends PolicyTemplateItem = PolicyTemplateItem>({
192
+ title = "Policy Templates",
193
+ description = "Create reusable templates for policies",
194
+ helpText,
195
+ fetchData,
196
+ onCreate,
197
+ onUpdate,
198
+ onDelete,
199
+ canEdit = true,
200
+ defaultCode = DEFAULT_CS_CODE,
201
+ codeLanguage = "csharp",
202
+ toast,
203
+ refreshInterval = 30,
204
+ invalidateQueries,
205
+ queryKeys,
206
+ columns: customColumns,
207
+ emptyState,
208
+ components = {},
209
+ className,
210
+ }: TemplatesPageBaseProps<T>) {
211
+ const [templates, setTemplates] = useState<T[]>([]);
212
+ const [isLoading, setIsLoading] = useState(false);
213
+ const [search, setSearch] = useState("");
214
+ const [editingTemplate, setEditingTemplate] = useState<T | null>(null);
215
+ const [creatingTemplate, setCreatingTemplate] = useState(false);
216
+ const [deletingTemplate, setDeletingTemplate] = useState<T | null>(null);
217
+ const [formData, setFormData] = useState<TemplateFormData>({
218
+ ...defaultFormData,
219
+ csCode: defaultCode,
220
+ });
221
+ const [isSubmitting, setIsSubmitting] = useState(false);
222
+
223
+ // Fetch data
224
+ const loadData = async () => {
225
+ setIsLoading(true);
226
+ try {
227
+ const result = await fetchData();
228
+ // Handle both array and object with templates property
229
+ const templateList = Array.isArray(result) ? result : (result.templates || []);
230
+ setTemplates(templateList as T[]);
231
+ if (invalidateQueries && queryKeys) {
232
+ invalidateQueries(queryKeys);
233
+ }
234
+ } catch (error) {
235
+ console.error("Error fetching templates:", error);
236
+ toast?.({
237
+ title: "Failed to fetch templates",
238
+ description: error instanceof Error ? error.message : "Unknown error",
239
+ variant: "destructive",
240
+ });
241
+ } finally {
242
+ setIsLoading(false);
243
+ }
244
+ };
245
+
246
+ const { secondsRemaining, refreshNow } = useAutoRefresh({
247
+ intervalSeconds: refreshInterval,
248
+ refresh: loadData,
249
+ isBlocked: isLoading || isSubmitting,
250
+ });
251
+
252
+ React.useEffect(() => {
253
+ void loadData();
254
+ }, []);
255
+
256
+ // Handlers
257
+ const handleEdit = (template: T) => {
258
+ setEditingTemplate(template);
259
+ setFormData({
260
+ name: template.name,
261
+ description: template.description,
262
+ csCode: template.csCode,
263
+ parameters: template.parameters || [],
264
+ });
265
+ };
266
+
267
+ const handleCreate = () => {
268
+ setFormData({ ...defaultFormData, csCode: defaultCode });
269
+ setCreatingTemplate(true);
270
+ };
271
+
272
+ const handleSubmit = async (e: React.FormEvent) => {
273
+ e.preventDefault();
274
+
275
+ if (!formData.name.trim()) {
276
+ toast?.({ title: "Template name is required", variant: "destructive" });
277
+ return;
278
+ }
279
+ if (!formData.description.trim()) {
280
+ toast?.({ title: "Description is required", variant: "destructive" });
281
+ return;
282
+ }
283
+ if (!formData.csCode.trim()) {
284
+ toast?.({ title: "Contract code is required", variant: "destructive" });
285
+ return;
286
+ }
287
+
288
+ setIsSubmitting(true);
289
+ try {
290
+ if (editingTemplate) {
291
+ await onUpdate(editingTemplate.id, formData);
292
+ toast?.({ title: "Template updated successfully" });
293
+ } else {
294
+ await onCreate(formData);
295
+ toast?.({ title: "Template created successfully" });
296
+ }
297
+ setCreatingTemplate(false);
298
+ setEditingTemplate(null);
299
+ setFormData({ ...defaultFormData, csCode: defaultCode });
300
+ await loadData();
301
+ } catch (error) {
302
+ toast?.({
303
+ title: editingTemplate ? "Failed to update template" : "Failed to create template",
304
+ description: error instanceof Error ? error.message : "Unknown error",
305
+ variant: "destructive",
306
+ });
307
+ } finally {
308
+ setIsSubmitting(false);
309
+ }
310
+ };
311
+
312
+ const handleDeleteConfirm = async () => {
313
+ if (!deletingTemplate) return;
314
+
315
+ setIsSubmitting(true);
316
+ try {
317
+ await onDelete(deletingTemplate.id);
318
+ toast?.({ title: "Template deleted successfully" });
319
+ setDeletingTemplate(null);
320
+ setEditingTemplate(null);
321
+ await loadData();
322
+ } catch (error) {
323
+ toast?.({
324
+ title: "Failed to delete template",
325
+ description: error instanceof Error ? error.message : "Unknown error",
326
+ variant: "destructive",
327
+ });
328
+ } finally {
329
+ setIsSubmitting(false);
330
+ }
331
+ };
332
+
333
+ // Parameter management
334
+ const addParameter = () => {
335
+ setFormData({
336
+ ...formData,
337
+ parameters: [
338
+ ...formData.parameters,
339
+ {
340
+ name: "",
341
+ type: "string",
342
+ helpText: "",
343
+ required: true,
344
+ defaultValue: "",
345
+ },
346
+ ],
347
+ });
348
+ };
349
+
350
+ const updateParameter = (index: number, field: keyof TemplateParameter, value: any) => {
351
+ const updated = [...formData.parameters];
352
+ updated[index] = { ...updated[index], [field]: value };
353
+ setFormData({ ...formData, parameters: updated });
354
+ };
355
+
356
+ const removeParameter = (index: number) => {
357
+ const updated = formData.parameters.filter((_, i) => i !== index);
358
+ setFormData({ ...formData, parameters: updated });
359
+ };
360
+
361
+ // Filter templates
362
+ const filteredTemplates = templates.filter(
363
+ (template) =>
364
+ template.name.toLowerCase().includes(search.toLowerCase()) ||
365
+ template.description.toLowerCase().includes(search.toLowerCase())
366
+ );
367
+
368
+ // Components (use defaultComponents from ui if not provided)
369
+ const Card = components.Card || defaultComponents.Card;
370
+ const CardContent = components.CardContent || defaultComponents.CardContent;
371
+ const Button = components.Button || defaultComponents.Button;
372
+ const Badge = components.Badge || defaultComponents.Badge;
373
+ const Input = components.Input || defaultComponents.Input;
374
+ const Textarea = components.Textarea || defaultComponents.Textarea;
375
+ const Label = components.Label || defaultComponents.Label;
376
+ const Switch = components.Switch || defaultComponents.Switch;
377
+ const Select = components.Select || defaultComponents.Select;
378
+ const SelectTrigger = components.SelectTrigger || defaultComponents.SelectTrigger;
379
+ const SelectValue = components.SelectValue || defaultComponents.SelectValue;
380
+ const SelectContent = components.SelectContent || defaultComponents.SelectContent;
381
+ const SelectItem = components.SelectItem || defaultComponents.SelectItem;
382
+ const Dialog = components.Dialog || defaultComponents.Dialog;
383
+ const DialogContent = components.DialogContent || defaultComponents.DialogContent;
384
+ const DialogHeader = components.DialogHeader || defaultComponents.DialogHeader;
385
+ const DialogTitle = components.DialogTitle || defaultComponents.DialogTitle;
386
+ const DialogDescription = components.DialogDescription || defaultComponents.DialogDescription;
387
+ const DialogFooter = components.DialogFooter || defaultComponents.DialogFooter;
388
+ const CodeEditor = components.CodeEditor || defaultComponents.CodeEditor;
389
+
390
+ const hasSelectComponents = Select && SelectTrigger && SelectValue && SelectContent && SelectItem;
391
+
392
+ // Default columns
393
+ const defaultColumns: ColumnDef<T>[] = [
394
+ {
395
+ key: "name",
396
+ header: "Template Name",
397
+ cell: (template) => (
398
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
399
+ <div style={{ display: "flex", height: "2.25rem", width: "2.25rem", alignItems: "center", justifyContent: "center", borderRadius: "9999px", backgroundColor: "rgba(59, 130, 246, 0.1)" }}>
400
+ <Code style={{ height: "1rem", width: "1rem", color: "#3b82f6" }} />
401
+ </div>
402
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
403
+ <p style={{ fontWeight: 500, margin: 0 }}>{template.name}</p>
404
+ {template.createdBy === "system" && (
405
+ <Badge variant="secondary" style={{ fontSize: "0.75rem" }}>System</Badge>
406
+ )}
407
+ </div>
408
+ </div>
409
+ ),
410
+ },
411
+ {
412
+ key: "description",
413
+ header: "Description",
414
+ cell: (template) => (
415
+ <p style={{ fontSize: "0.875rem", color: "#6b7280", margin: 0, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }}>{template.description}</p>
416
+ ),
417
+ },
418
+ {
419
+ key: "parameters",
420
+ header: "Parameters",
421
+ cell: (template) => (
422
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.25rem" }}>
423
+ {template.parameters.slice(0, 3).map((param) => (
424
+ <Badge key={param.name} variant="outline" style={{ fontSize: "0.75rem" }}>{param.name}</Badge>
425
+ ))}
426
+ {template.parameters.length > 3 && (
427
+ <Badge variant="outline" style={{ fontSize: "0.75rem" }}>+{template.parameters.length - 3}</Badge>
428
+ )}
429
+ {template.parameters.length === 0 && (
430
+ <span style={{ fontSize: "0.75rem", color: "#6b7280" }}>None</span>
431
+ )}
432
+ </div>
433
+ ),
434
+ },
435
+ {
436
+ key: "createdBy",
437
+ header: "Created By",
438
+ cell: (template) => (
439
+ <span style={{ fontSize: "0.875rem", color: "#6b7280" }}>{template.createdBy}</span>
440
+ ),
441
+ },
442
+ {
443
+ key: "actions",
444
+ header: "Actions",
445
+ align: "right",
446
+ cell: (template) => (
447
+ canEdit && (
448
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
449
+ <Button size="icon" variant="ghost" onClick={() => handleEdit(template)}>
450
+ <Pencil style={{ height: "1rem", width: "1rem" }} />
451
+ </Button>
452
+ {template.createdBy !== "system" && (
453
+ <Button size="icon" variant="ghost" onClick={() => setDeletingTemplate(template)}>
454
+ <Trash2 style={{ height: "1rem", width: "1rem" }} />
455
+ </Button>
456
+ )}
457
+ </div>
458
+ )
459
+ ),
460
+ },
461
+ ];
462
+
463
+ const columns = customColumns || defaultColumns;
464
+ const isDialogOpen = creatingTemplate || !!editingTemplate;
465
+
466
+ return (
467
+ <div style={{ padding: "1.5rem" }} className={className}>
468
+ {/* Header */}
469
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.5rem", flexWrap: "wrap", gap: "1rem" }}>
470
+ <div>
471
+ <h1 style={{ fontSize: "1.5rem", fontWeight: 600, display: "flex", alignItems: "center", gap: "0.5rem", margin: 0 }}>
472
+ <FileCode style={{ width: "1.5rem", height: "1.5rem" }} />
473
+ {title}
474
+ </h1>
475
+ <p style={{ fontSize: "0.875rem", color: "#6b7280", margin: "0.25rem 0 0" }}>{description}</p>
476
+ {helpText && <div style={{ fontSize: "0.75rem", color: "#6b7280", marginTop: "0.25rem" }}>{helpText}</div>}
477
+ </div>
478
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
479
+ <RefreshButton
480
+ onClick={() => void refreshNow()}
481
+ isRefreshing={isLoading}
482
+ secondsRemaining={secondsRemaining}
483
+ title="Refresh now"
484
+ />
485
+ {canEdit && (
486
+ <Button onClick={handleCreate}>
487
+ <Plus style={{ height: "1rem", width: "1rem", marginRight: "0.5rem" }} />
488
+ Create Template
489
+ </Button>
490
+ )}
491
+ </div>
492
+ </div>
493
+
494
+ {/* Search & Table */}
495
+ <Card>
496
+ <div style={{ padding: "1rem", borderBottom: "1px solid #e5e7eb" }}>
497
+ <div style={{ position: "relative", maxWidth: "20rem" }}>
498
+ <Search style={{ position: "absolute", left: "0.75rem", top: "50%", transform: "translateY(-50%)", width: "1rem", height: "1rem", color: "#9ca3af" }} />
499
+ <Input
500
+ placeholder="Search templates..."
501
+ value={search}
502
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
503
+ style={{ paddingLeft: "2.25rem" }}
504
+ />
505
+ </div>
506
+ </div>
507
+ <CardContent style={{ padding: 0 }}>
508
+ {isLoading && templates.length === 0 ? (
509
+ <LoadingSkeleton rows={3} type="table" SkeletonComponent={components.Skeleton} />
510
+ ) : filteredTemplates.length > 0 ? (
511
+ <DataTable
512
+ data={filteredTemplates}
513
+ columns={columns}
514
+ components={{
515
+ Table: components.Table,
516
+ TableHeader: components.TableHeader,
517
+ TableBody: components.TableBody,
518
+ TableRow: components.TableRow,
519
+ TableHead: components.TableHead,
520
+ TableCell: components.TableCell,
521
+ Skeleton: components.Skeleton,
522
+ }}
523
+ />
524
+ ) : (
525
+ <EmptyState
526
+ icon={emptyState?.icon || <FileCode style={{ height: "3rem", width: "3rem", color: "#6b7280" }} />}
527
+ title={emptyState?.title || "No templates found"}
528
+ description={emptyState?.description || (search ? "Try a different search term" : "Create a template to get started")}
529
+ />
530
+ )}
531
+ </CardContent>
532
+ </Card>
533
+
534
+ {/* Create/Edit Dialog */}
535
+ <Dialog open={isDialogOpen} onOpenChange={(open: boolean) => {
536
+ if (!open) {
537
+ setCreatingTemplate(false);
538
+ setEditingTemplate(null);
539
+ }
540
+ }}>
541
+ <DialogContent style={{ maxWidth: "56rem", maxHeight: "90vh", overflow: "auto" }}>
542
+ <DialogHeader>
543
+ <DialogTitle>{editingTemplate ? "Edit Template" : "Create Template"}</DialogTitle>
544
+ <DialogDescription>
545
+ {editingTemplate
546
+ ? "Update the template configuration and contract code"
547
+ : "Create a reusable policy template with configurable parameters"}
548
+ </DialogDescription>
549
+ </DialogHeader>
550
+ <form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
551
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
552
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
553
+ <Label htmlFor="name">Template Name</Label>
554
+ <Input
555
+ id="name"
556
+ value={formData.name}
557
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, name: e.target.value })}
558
+ placeholder="e.g., SSH Access Policy"
559
+ required
560
+ />
561
+ </div>
562
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", gridColumn: "span 2" }}>
563
+ <Label htmlFor="description">Description</Label>
564
+ <Textarea
565
+ id="description"
566
+ value={formData.description}
567
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData({ ...formData, description: e.target.value })}
568
+ placeholder="Describe what this template does and when to use it..."
569
+ rows={2}
570
+ required
571
+ />
572
+ </div>
573
+ </div>
574
+
575
+ {/* Code Editor */}
576
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
577
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
578
+ <Label>Contract Code ({codeLanguage})</Label>
579
+ <p style={{ fontSize: "0.75rem", color: "#6b7280", margin: 0 }}>
580
+ Use <code style={{ backgroundColor: "#f3f4f6", padding: "0 0.25rem", borderRadius: "0.25rem" }}>{"{{PARAM_NAME}}"}</code> for dynamic values
581
+ </p>
582
+ </div>
583
+ <div style={{ border: "1px solid #e5e7eb", borderRadius: "0.375rem", overflow: "hidden" }}>
584
+ <CodeEditor
585
+ value={formData.csCode}
586
+ onChange={(value) => setFormData({ ...formData, csCode: value })}
587
+ language={codeLanguage}
588
+ height="300px"
589
+ />
590
+ </div>
591
+ </div>
592
+
593
+ {/* Parameters Section */}
594
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
595
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
596
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
597
+ <Variable style={{ height: "1rem", width: "1rem", color: "#6b7280" }} />
598
+ <Label>Template Parameters</Label>
599
+ </div>
600
+ <Button type="button" variant="outline" size="sm" onClick={addParameter}>
601
+ <Plus style={{ height: "1rem", width: "1rem", marginRight: "0.25rem" }} />
602
+ Add Parameter
603
+ </Button>
604
+ </div>
605
+ <p style={{ fontSize: "0.75rem", color: "#6b7280", margin: 0 }}>
606
+ Define parameters that users will fill in when using this template.
607
+ </p>
608
+
609
+ {formData.parameters.length === 0 ? (
610
+ <div style={{ border: "1px solid #e5e7eb", borderRadius: "0.375rem", padding: "1rem", textAlign: "center", fontSize: "0.875rem", color: "#6b7280" }}>
611
+ No parameters defined. Add parameters to make your template configurable.
612
+ </div>
613
+ ) : (
614
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
615
+ {formData.parameters.map((param, index) => (
616
+ <div key={index} style={{ border: "1px solid #e5e7eb", borderRadius: "0.375rem", padding: "1rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
617
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
618
+ <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Parameter {index + 1}</span>
619
+ <Button type="button" variant="ghost" size="sm" onClick={() => removeParameter(index)}>
620
+ <Trash2 style={{ height: "1rem", width: "1rem" }} />
621
+ </Button>
622
+ </div>
623
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
624
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
625
+ <Label style={{ fontSize: "0.75rem" }}>Name (placeholder)</Label>
626
+ <Input
627
+ value={param.name}
628
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateParameter(index, "name", e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, "_"))}
629
+ placeholder="e.g., APPROVAL_TYPE"
630
+ style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
631
+ />
632
+ <p style={{ fontSize: "0.75rem", color: "#6b7280", margin: 0 }}>
633
+ Use in code as: <code>{`{{${param.name || "NAME"}}}`}</code>
634
+ </p>
635
+ </div>
636
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
637
+ <Label style={{ fontSize: "0.75rem" }}>Type</Label>
638
+ {hasSelectComponents ? (
639
+ <Select
640
+ value={param.type}
641
+ onValueChange={(v: string) => updateParameter(index, "type", v)}
642
+ >
643
+ <SelectTrigger>
644
+ <SelectValue />
645
+ </SelectTrigger>
646
+ <SelectContent>
647
+ <SelectItem value="string">Text</SelectItem>
648
+ <SelectItem value="number">Number</SelectItem>
649
+ <SelectItem value="boolean">Yes/No</SelectItem>
650
+ <SelectItem value="select">Select (Dropdown)</SelectItem>
651
+ </SelectContent>
652
+ </Select>
653
+ ) : (
654
+ <select
655
+ value={param.type}
656
+ onChange={(e) => updateParameter(index, "type", e.target.value)}
657
+ style={{ display: "flex", height: "2.5rem", width: "100%", borderRadius: "0.375rem", border: "1px solid #d1d5db", backgroundColor: "#fff", padding: "0.5rem 0.75rem", fontSize: "0.875rem" }}
658
+ >
659
+ <option value="string">Text</option>
660
+ <option value="number">Number</option>
661
+ <option value="boolean">Yes/No</option>
662
+ <option value="select">Select (Dropdown)</option>
663
+ </select>
664
+ )}
665
+ </div>
666
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem", gridColumn: "span 2" }}>
667
+ <Label style={{ fontSize: "0.75rem" }}>Help Text</Label>
668
+ <Input
669
+ value={param.helpText}
670
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateParameter(index, "helpText", e.target.value)}
671
+ placeholder="Explain what this parameter does..."
672
+ />
673
+ </div>
674
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
675
+ <Label style={{ fontSize: "0.75rem" }}>Default Value</Label>
676
+ <Input
677
+ value={param.defaultValue?.toString() || ""}
678
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateParameter(index, "defaultValue", e.target.value)}
679
+ placeholder="Optional default"
680
+ />
681
+ </div>
682
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", paddingTop: "1.25rem" }}>
683
+ <Switch
684
+ checked={param.required}
685
+ onCheckedChange={(v: boolean) => updateParameter(index, "required", v)}
686
+ />
687
+ <Label style={{ fontSize: "0.75rem" }}>Required</Label>
688
+ </div>
689
+ {param.type === "select" && (
690
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem", gridColumn: "span 2" }}>
691
+ <Label style={{ fontSize: "0.75rem" }}>Options (comma-separated)</Label>
692
+ <Input
693
+ value={param.options?.join(", ") || ""}
694
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateParameter(index, "options", e.target.value.split(",").map(s => s.trim()).filter(Boolean))}
695
+ placeholder="e.g., option1, option2, option3"
696
+ />
697
+ </div>
698
+ )}
699
+ </div>
700
+ </div>
701
+ ))}
702
+ </div>
703
+ )}
704
+ </div>
705
+
706
+ <DialogFooter style={{ display: "flex", justifyContent: "space-between" }}>
707
+ {editingTemplate && editingTemplate.createdBy !== "system" && (
708
+ <Button
709
+ type="button"
710
+ variant="destructive"
711
+ onClick={() => setDeletingTemplate(editingTemplate)}
712
+ >
713
+ <Trash2 style={{ height: "1rem", width: "1rem", marginRight: "0.5rem" }} />
714
+ Delete
715
+ </Button>
716
+ )}
717
+ {(!editingTemplate || editingTemplate.createdBy === "system") && <div />}
718
+ <div style={{ display: "flex", gap: "0.5rem" }}>
719
+ <Button
720
+ type="button"
721
+ variant="outline"
722
+ onClick={() => {
723
+ setCreatingTemplate(false);
724
+ setEditingTemplate(null);
725
+ }}
726
+ >
727
+ Cancel
728
+ </Button>
729
+ <Button type="submit" disabled={isSubmitting}>
730
+ {isSubmitting ? "Saving..." : editingTemplate ? "Save Changes" : "Create Template"}
731
+ </Button>
732
+ </div>
733
+ </DialogFooter>
734
+ </form>
735
+ </DialogContent>
736
+ </Dialog>
737
+
738
+ {/* Delete Confirmation */}
739
+ <ConfirmDialog
740
+ open={!!deletingTemplate}
741
+ onClose={() => setDeletingTemplate(null)}
742
+ onConfirm={handleDeleteConfirm}
743
+ title="Delete Template"
744
+ description={`Are you sure you want to delete "${deletingTemplate?.name}"? This action cannot be undone.`}
745
+ confirmLabel="Delete"
746
+ confirmVariant="destructive"
747
+ isLoading={isSubmitting}
748
+ components={{
749
+ AlertDialog: components.AlertDialog,
750
+ AlertDialogContent: components.AlertDialogContent,
751
+ AlertDialogHeader: components.AlertDialogHeader,
752
+ AlertDialogTitle: components.AlertDialogTitle,
753
+ AlertDialogDescription: components.AlertDialogDescription,
754
+ AlertDialogFooter: components.AlertDialogFooter,
755
+ AlertDialogAction: components.AlertDialogAction,
756
+ AlertDialogCancel: components.AlertDialogCancel,
757
+ }}
758
+ />
759
+ </div>
760
+ );
761
+ }