@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,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
|
+
}
|