@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,1470 @@
|
|
|
1
|
+
import React, { useState, useMemo, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
KeyRound,
|
|
4
|
+
Pencil,
|
|
5
|
+
Plus,
|
|
6
|
+
Trash2,
|
|
7
|
+
Search,
|
|
8
|
+
Shield,
|
|
9
|
+
FileCode,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { useAutoRefresh } from "../../../hooks/useAutoRefresh";
|
|
12
|
+
import { RefreshButton } from "../../common/RefreshButton";
|
|
13
|
+
import { defaultComponents } from "../../ui";
|
|
14
|
+
import type { BaseDataItem, ColumnDef, FormFieldDef } from "../../../types";
|
|
15
|
+
|
|
16
|
+
export interface RoleItem extends BaseDataItem {
|
|
17
|
+
name: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
clientRole?: boolean;
|
|
20
|
+
/** For display purposes - which type this role belongs to */
|
|
21
|
+
roleType?: "realm" | "client";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Role type configuration for RolesPage */
|
|
25
|
+
export type RoleTypeConfig = "realm" | "client" | "all";
|
|
26
|
+
|
|
27
|
+
export interface PolicyTemplateItem {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
csCode: string;
|
|
32
|
+
parameters: TemplateParameter[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TemplateParameter {
|
|
36
|
+
name: string;
|
|
37
|
+
type: "string" | "number" | "boolean" | "select";
|
|
38
|
+
required?: boolean;
|
|
39
|
+
defaultValue?: string | number | boolean;
|
|
40
|
+
helpText?: string;
|
|
41
|
+
options?: string[]; // For select type
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PolicyConfig {
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
contractType: string;
|
|
47
|
+
approvalType: "implicit" | "explicit";
|
|
48
|
+
executionType: "public" | "private";
|
|
49
|
+
threshold: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RolesPageBaseProps<TRole extends RoleItem = RoleItem> {
|
|
53
|
+
/** Page title */
|
|
54
|
+
title?: string;
|
|
55
|
+
/** Page description */
|
|
56
|
+
description?: string;
|
|
57
|
+
/** Help text shown below description */
|
|
58
|
+
helpText?: React.ReactNode;
|
|
59
|
+
/** SSH role info text */
|
|
60
|
+
sshRoleInfo?: string;
|
|
61
|
+
/** Whether the API is ready to be called (defaults to true for backwards compatibility) */
|
|
62
|
+
isReady?: boolean;
|
|
63
|
+
/** Role type configuration: 'realm', 'client', or 'all' (default: 'realm') */
|
|
64
|
+
roleType?: RoleTypeConfig;
|
|
65
|
+
/** Special role prefix (e.g., "ssh:", "admin:"). Used to identify and auto-prefix special roles. */
|
|
66
|
+
specialRolePrefix?: string;
|
|
67
|
+
/** Special role type label (e.g., "SSH", "Admin"). Used for display purposes. */
|
|
68
|
+
specialRoleType?: string;
|
|
69
|
+
|
|
70
|
+
// Data fetching
|
|
71
|
+
/** Fetch roles */
|
|
72
|
+
fetchRoles: () => Promise<{ roles: TRole[] } | TRole[]>;
|
|
73
|
+
/** Fetch policy templates */
|
|
74
|
+
fetchTemplates?: () => Promise<{ templates: PolicyTemplateItem[] } | PolicyTemplateItem[]>;
|
|
75
|
+
/** Fetch users for threshold calculation */
|
|
76
|
+
fetchUsers?: () => Promise<any[]>;
|
|
77
|
+
/** Fetch pending approvals */
|
|
78
|
+
fetchPendingApprovals?: () => Promise<any[]>;
|
|
79
|
+
/** Fetch existing policy for a role */
|
|
80
|
+
fetchRolePolicy?: (roleName: string) => Promise<{ policy: PolicyConfig | null }>;
|
|
81
|
+
|
|
82
|
+
// Actions
|
|
83
|
+
/** Create role */
|
|
84
|
+
onCreate: (data: { name: string; description?: string; policy?: PolicyConfig; roleType?: "realm" | "client" }) => Promise<void>;
|
|
85
|
+
/** Update role */
|
|
86
|
+
onUpdate: (data: { name: string; description?: string }) => Promise<void>;
|
|
87
|
+
/** Delete role */
|
|
88
|
+
onDelete: (roleName: string) => Promise<{ approvalCreated?: boolean }>;
|
|
89
|
+
/** Create policy request (for SSH roles) */
|
|
90
|
+
onCreatePolicy?: (params: {
|
|
91
|
+
roleName: string;
|
|
92
|
+
policyConfig: PolicyConfig;
|
|
93
|
+
templateId?: string;
|
|
94
|
+
templateParams?: Record<string, any>;
|
|
95
|
+
threshold: number;
|
|
96
|
+
}) => Promise<void>;
|
|
97
|
+
|
|
98
|
+
// Configuration
|
|
99
|
+
/** Contract type options */
|
|
100
|
+
contractTypes?: { value: string; label: string; description?: string }[];
|
|
101
|
+
/** Default policy config */
|
|
102
|
+
defaultPolicyConfig?: PolicyConfig;
|
|
103
|
+
/** Check if role is a special prefixed role */
|
|
104
|
+
isSpecialRole?: (role: TRole | string) => boolean;
|
|
105
|
+
/** Normalize special role name (add prefix) */
|
|
106
|
+
normalizeSpecialRoleName?: (name: string) => string;
|
|
107
|
+
/** Customizable UI labels */
|
|
108
|
+
labels?: {
|
|
109
|
+
/** Special role prefix (default: "ssh:") */
|
|
110
|
+
specialRolePrefix?: string;
|
|
111
|
+
/** Special role type label (default: "SSH") */
|
|
112
|
+
specialRoleType?: string;
|
|
113
|
+
/** Special role checkbox label */
|
|
114
|
+
specialRoleCheckbox?: string;
|
|
115
|
+
/** Special role hint text */
|
|
116
|
+
specialRoleHint?: string;
|
|
117
|
+
/** Policy section title */
|
|
118
|
+
policySectionTitle?: string;
|
|
119
|
+
/** Policy section description */
|
|
120
|
+
policySectionDescription?: string;
|
|
121
|
+
/** Default policy option label */
|
|
122
|
+
defaultPolicyLabel?: string;
|
|
123
|
+
/** Default policy description */
|
|
124
|
+
defaultPolicyDescription?: string;
|
|
125
|
+
/** Create policy checkbox label */
|
|
126
|
+
createPolicyCheckbox?: string;
|
|
127
|
+
/** Role type labels */
|
|
128
|
+
clientRoleLabel?: string;
|
|
129
|
+
realmRoleLabel?: string;
|
|
130
|
+
/** Info text shown in header */
|
|
131
|
+
specialRoleInfo?: string;
|
|
132
|
+
/** Button labels */
|
|
133
|
+
addRoleButton?: string;
|
|
134
|
+
createButton?: string;
|
|
135
|
+
saveButton?: string;
|
|
136
|
+
cancelButton?: string;
|
|
137
|
+
deleteButton?: string;
|
|
138
|
+
/** Dialog titles */
|
|
139
|
+
addRoleTitle?: string;
|
|
140
|
+
editRoleTitle?: string;
|
|
141
|
+
deleteRoleTitle?: string;
|
|
142
|
+
/** Placeholders */
|
|
143
|
+
searchPlaceholder?: string;
|
|
144
|
+
roleNamePlaceholder?: string;
|
|
145
|
+
specialRoleNamePlaceholder?: string;
|
|
146
|
+
descriptionPlaceholder?: string;
|
|
147
|
+
/** Empty state */
|
|
148
|
+
noRolesTitle?: string;
|
|
149
|
+
noRolesDescription?: string;
|
|
150
|
+
/** No search results message */
|
|
151
|
+
noSearchResultsMessage?: string;
|
|
152
|
+
/** Messages */
|
|
153
|
+
roleCreatedMessage?: string;
|
|
154
|
+
roleUpdatedMessage?: string;
|
|
155
|
+
roleDeletedMessage?: string;
|
|
156
|
+
policyCreatedMessage?: string;
|
|
157
|
+
/** Badge text for special roles in table */
|
|
158
|
+
specialRoleBadge?: string;
|
|
159
|
+
/** Description for create role dialog */
|
|
160
|
+
createRoleDescription?: string;
|
|
161
|
+
/** Description for edit role dialog */
|
|
162
|
+
editRoleDescription?: string;
|
|
163
|
+
/** Role name disabled hint */
|
|
164
|
+
roleNameDisabledHint?: string;
|
|
165
|
+
/** Policy update warning */
|
|
166
|
+
policyUpdateWarning?: string;
|
|
167
|
+
/** Delete confirmation message */
|
|
168
|
+
deleteConfirmMessage?: string;
|
|
169
|
+
/** Approval request message */
|
|
170
|
+
approvalRequestMessage?: string;
|
|
171
|
+
/** No description text */
|
|
172
|
+
noDescriptionText?: string;
|
|
173
|
+
/** Loading policy text */
|
|
174
|
+
loadingPolicyText?: string;
|
|
175
|
+
/** Update signing policy checkbox label */
|
|
176
|
+
updatePolicyCheckbox?: string;
|
|
177
|
+
/** Policy template label */
|
|
178
|
+
policyTemplateLabel?: string;
|
|
179
|
+
};
|
|
180
|
+
/** Calculate threshold from users */
|
|
181
|
+
calculateThreshold?: (users: any[], pendingApprovals: any[]) => number;
|
|
182
|
+
/** Admin role set for threshold calculation */
|
|
183
|
+
adminRoleSet?: Set<string>;
|
|
184
|
+
|
|
185
|
+
// UI
|
|
186
|
+
/** Custom columns for role table */
|
|
187
|
+
columns?: ColumnDef<TRole>[];
|
|
188
|
+
/** Toast function */
|
|
189
|
+
toast?: (options: { title: string; description?: string; variant?: string }) => void;
|
|
190
|
+
/** Invalidate queries */
|
|
191
|
+
invalidateQueries?: (keys: string[]) => void;
|
|
192
|
+
/** Query keys to invalidate on changes */
|
|
193
|
+
queryKeys?: string[];
|
|
194
|
+
/** Auto-refresh interval in seconds */
|
|
195
|
+
refreshInterval?: number;
|
|
196
|
+
/** Additional class name */
|
|
197
|
+
className?: string;
|
|
198
|
+
|
|
199
|
+
// Custom components
|
|
200
|
+
components?: {
|
|
201
|
+
Card?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
|
|
202
|
+
CardContent?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
|
|
203
|
+
Button?: React.ComponentType<any>;
|
|
204
|
+
Badge?: React.ComponentType<{ children: React.ReactNode; variant?: string; className?: string }>;
|
|
205
|
+
Skeleton?: React.ComponentType<{ className?: string }>;
|
|
206
|
+
Input?: React.ComponentType<any>;
|
|
207
|
+
Label?: React.ComponentType<any>;
|
|
208
|
+
Textarea?: React.ComponentType<any>;
|
|
209
|
+
Checkbox?: React.ComponentType<any>;
|
|
210
|
+
Select?: React.ComponentType<any>;
|
|
211
|
+
SelectTrigger?: React.ComponentType<any>;
|
|
212
|
+
SelectValue?: React.ComponentType<any>;
|
|
213
|
+
SelectContent?: React.ComponentType<any>;
|
|
214
|
+
SelectItem?: React.ComponentType<any>;
|
|
215
|
+
Table?: React.ComponentType<{ children: React.ReactNode }>;
|
|
216
|
+
TableHeader?: React.ComponentType<{ children: React.ReactNode }>;
|
|
217
|
+
TableBody?: React.ComponentType<{ children: React.ReactNode }>;
|
|
218
|
+
TableRow?: React.ComponentType<{ children: React.ReactNode }>;
|
|
219
|
+
TableHead?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
|
|
220
|
+
TableCell?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
|
|
221
|
+
Dialog?: React.ComponentType<{ open: boolean; onOpenChange: (open: boolean) => void; children: React.ReactNode }>;
|
|
222
|
+
DialogContent?: React.ComponentType<{ className?: string; children: React.ReactNode }>;
|
|
223
|
+
DialogHeader?: React.ComponentType<{ children: React.ReactNode }>;
|
|
224
|
+
DialogTitle?: React.ComponentType<{ children: React.ReactNode }>;
|
|
225
|
+
DialogDescription?: React.ComponentType<{ children: React.ReactNode }>;
|
|
226
|
+
DialogFooter?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
|
|
227
|
+
AlertDialog?: React.ComponentType<{ open: boolean; onOpenChange: (open: boolean) => void; children: React.ReactNode }>;
|
|
228
|
+
AlertDialogContent?: React.ComponentType<{ children: React.ReactNode }>;
|
|
229
|
+
AlertDialogHeader?: React.ComponentType<{ children: React.ReactNode }>;
|
|
230
|
+
AlertDialogTitle?: React.ComponentType<{ children: React.ReactNode }>;
|
|
231
|
+
AlertDialogDescription?: React.ComponentType<{ children: React.ReactNode }>;
|
|
232
|
+
AlertDialogFooter?: React.ComponentType<{ children: React.ReactNode }>;
|
|
233
|
+
AlertDialogCancel?: React.ComponentType<{ children: React.ReactNode }>;
|
|
234
|
+
AlertDialogAction?: React.ComponentType<any>;
|
|
235
|
+
Separator?: React.ComponentType<{ className?: string }>;
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Default contract types
|
|
240
|
+
const DEFAULT_CONTRACT_TYPES = [
|
|
241
|
+
{ value: "BasicCustom<Policy>:BasicCustom<1>", label: "Basic - All data at creation time" },
|
|
242
|
+
{ value: "DynamicCustom<Policy>:DynamicCustom<1>", label: "Dynamic - Challenge can change" },
|
|
243
|
+
{ value: "DynamicApprovedCustom<Policy>:DynamicApprovedCustom<1>", label: "Dynamic Approved - With human readable approval" },
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const DEFAULT_POLICY_CONFIG: PolicyConfig = {
|
|
247
|
+
enabled: true,
|
|
248
|
+
contractType: "BasicCustom<Policy>:BasicCustom<1>",
|
|
249
|
+
approvalType: "implicit",
|
|
250
|
+
executionType: "private",
|
|
251
|
+
threshold: 1,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// ============================================================================
|
|
255
|
+
// Component
|
|
256
|
+
// ============================================================================
|
|
257
|
+
|
|
258
|
+
// Default labels
|
|
259
|
+
const DEFAULT_LABELS = {
|
|
260
|
+
specialRoleCheckbox: "Special role (auto-prefix)",
|
|
261
|
+
specialRoleHint: "This will create a prefixed role.",
|
|
262
|
+
policySectionTitle: "Signing Policy",
|
|
263
|
+
policySectionDescription: "Configure the policy for this role.",
|
|
264
|
+
defaultPolicyLabel: "Default Policy",
|
|
265
|
+
defaultPolicyDescription: "Uses the built-in policy contract",
|
|
266
|
+
createPolicyCheckbox: "Create signing policy for this role",
|
|
267
|
+
clientRoleLabel: "Client Role",
|
|
268
|
+
realmRoleLabel: "Realm Role",
|
|
269
|
+
specialRoleInfo: "",
|
|
270
|
+
addRoleButton: "Add Role",
|
|
271
|
+
createButton: "Create Role",
|
|
272
|
+
saveButton: "Save Changes",
|
|
273
|
+
cancelButton: "Cancel",
|
|
274
|
+
deleteButton: "Delete",
|
|
275
|
+
addRoleTitle: "Add New Role",
|
|
276
|
+
editRoleTitle: "Edit Role",
|
|
277
|
+
deleteRoleTitle: "Delete Role",
|
|
278
|
+
searchPlaceholder: "Search roles...",
|
|
279
|
+
roleNamePlaceholder: "e.g., developer",
|
|
280
|
+
specialRoleNamePlaceholder: "e.g., root",
|
|
281
|
+
descriptionPlaceholder: "Describe the role's purpose...",
|
|
282
|
+
noRolesTitle: "No roles found",
|
|
283
|
+
noRolesDescription: "Create a role to get started",
|
|
284
|
+
noSearchResultsMessage: "Try a different search term",
|
|
285
|
+
roleCreatedMessage: "Role created successfully",
|
|
286
|
+
roleUpdatedMessage: "Role updated successfully",
|
|
287
|
+
roleDeletedMessage: "Role deleted successfully",
|
|
288
|
+
policyCreatedMessage: "Policy request created",
|
|
289
|
+
specialRoleBadge: "Special",
|
|
290
|
+
createRoleDescription: "Create a new role for access control",
|
|
291
|
+
editRoleDescription: "Update the role settings",
|
|
292
|
+
roleNameDisabledHint: "Role names cannot be changed",
|
|
293
|
+
policyUpdateWarning: "Updating the policy will create a new pending approval request that must be approved by admins.",
|
|
294
|
+
deleteConfirmMessage: "Are you sure you want to delete the role \"{name}\"? This action cannot be undone.",
|
|
295
|
+
approvalRequestMessage: "Role has users assigned. An approval request has been created for review.",
|
|
296
|
+
noDescriptionText: "No description",
|
|
297
|
+
loadingPolicyText: "Loading policy...",
|
|
298
|
+
updatePolicyCheckbox: "Update signing policy",
|
|
299
|
+
policyTemplateLabel: "Policy Template",
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export function RolesPageBase<TRole extends RoleItem = RoleItem>({
|
|
303
|
+
title = "Manage Roles",
|
|
304
|
+
description = "Create and manage user roles for access control",
|
|
305
|
+
helpText,
|
|
306
|
+
isReady = true,
|
|
307
|
+
roleType = "realm",
|
|
308
|
+
specialRolePrefix = "roleWithPolicy:",
|
|
309
|
+
specialRoleType = "Policy Role",
|
|
310
|
+
fetchRoles,
|
|
311
|
+
fetchTemplates,
|
|
312
|
+
fetchUsers,
|
|
313
|
+
fetchPendingApprovals,
|
|
314
|
+
fetchRolePolicy,
|
|
315
|
+
onCreate,
|
|
316
|
+
onUpdate,
|
|
317
|
+
onDelete,
|
|
318
|
+
onCreatePolicy,
|
|
319
|
+
contractTypes = DEFAULT_CONTRACT_TYPES,
|
|
320
|
+
defaultPolicyConfig = DEFAULT_POLICY_CONFIG,
|
|
321
|
+
isSpecialRole: isSpecialRoleProp,
|
|
322
|
+
normalizeSpecialRoleName: normalizeSpecialRoleNameProp,
|
|
323
|
+
labels: customLabels = {},
|
|
324
|
+
calculateThreshold,
|
|
325
|
+
adminRoleSet,
|
|
326
|
+
columns,
|
|
327
|
+
toast,
|
|
328
|
+
invalidateQueries,
|
|
329
|
+
queryKeys = [],
|
|
330
|
+
refreshInterval = 15,
|
|
331
|
+
className,
|
|
332
|
+
components = {},
|
|
333
|
+
}: RolesPageBaseProps<TRole>) {
|
|
334
|
+
// Merge labels with defaults, using specialRolePrefix and specialRoleType for default labels
|
|
335
|
+
const labels = {
|
|
336
|
+
...DEFAULT_LABELS,
|
|
337
|
+
specialRolePrefix,
|
|
338
|
+
specialRoleType,
|
|
339
|
+
...customLabels,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Create default implementations using the prefix
|
|
343
|
+
const prefixPattern = specialRolePrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/:$/, '[:\\-]');
|
|
344
|
+
const prefixRegex = new RegExp(`^${prefixPattern}`, 'i');
|
|
345
|
+
|
|
346
|
+
const isSpecialRole = isSpecialRoleProp ?? ((role: TRole | string) => {
|
|
347
|
+
const name = typeof role === "string" ? role : role.name;
|
|
348
|
+
return prefixRegex.test(name);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const normalizeSpecialRoleName = normalizeSpecialRoleNameProp ?? ((value: string) => {
|
|
352
|
+
const trimmed = value.trim();
|
|
353
|
+
if (!trimmed) return "";
|
|
354
|
+
if (prefixRegex.test(trimmed)) return trimmed;
|
|
355
|
+
return `${specialRolePrefix}${trimmed}`;
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Get components (use defaultComponents from ui if not provided)
|
|
359
|
+
const Card = components.Card || defaultComponents.Card;
|
|
360
|
+
const CardContent = components.CardContent || defaultComponents.CardContent;
|
|
361
|
+
const Button = components.Button || defaultComponents.Button;
|
|
362
|
+
const Badge = components.Badge || defaultComponents.Badge;
|
|
363
|
+
const Skeleton = components.Skeleton || defaultComponents.Skeleton;
|
|
364
|
+
const Input = components.Input || defaultComponents.Input;
|
|
365
|
+
const Label = components.Label || defaultComponents.Label;
|
|
366
|
+
const Textarea = components.Textarea || defaultComponents.Textarea;
|
|
367
|
+
const Checkbox = components.Checkbox || defaultComponents.Checkbox;
|
|
368
|
+
const Select = components.Select || defaultComponents.Select;
|
|
369
|
+
const SelectTrigger = components.SelectTrigger || defaultComponents.SelectTrigger;
|
|
370
|
+
const SelectValue = components.SelectValue || defaultComponents.SelectValue;
|
|
371
|
+
const SelectContent = components.SelectContent || defaultComponents.SelectContent;
|
|
372
|
+
const SelectItem = components.SelectItem || defaultComponents.SelectItem;
|
|
373
|
+
const Table = components.Table || defaultComponents.Table;
|
|
374
|
+
const TableHeader = components.TableHeader || defaultComponents.TableHeader;
|
|
375
|
+
const TableBody = components.TableBody || defaultComponents.TableBody;
|
|
376
|
+
const TableRow = components.TableRow || defaultComponents.TableRow;
|
|
377
|
+
const TableHead = components.TableHead || defaultComponents.TableHead;
|
|
378
|
+
const TableCell = components.TableCell || defaultComponents.TableCell;
|
|
379
|
+
const Dialog = components.Dialog || defaultComponents.Dialog;
|
|
380
|
+
const DialogContent = components.DialogContent || defaultComponents.DialogContent;
|
|
381
|
+
const DialogHeader = components.DialogHeader || defaultComponents.DialogHeader;
|
|
382
|
+
const DialogTitle = components.DialogTitle || defaultComponents.DialogTitle;
|
|
383
|
+
const DialogDescription = components.DialogDescription || defaultComponents.DialogDescription;
|
|
384
|
+
const DialogFooter = components.DialogFooter || defaultComponents.DialogFooter;
|
|
385
|
+
const AlertDialog = components.AlertDialog || defaultComponents.AlertDialog;
|
|
386
|
+
const AlertDialogContent = components.AlertDialogContent || defaultComponents.AlertDialogContent;
|
|
387
|
+
const AlertDialogHeader = components.AlertDialogHeader || defaultComponents.AlertDialogHeader;
|
|
388
|
+
const AlertDialogTitle = components.AlertDialogTitle || defaultComponents.AlertDialogTitle;
|
|
389
|
+
const AlertDialogDescription = components.AlertDialogDescription || defaultComponents.AlertDialogDescription;
|
|
390
|
+
const AlertDialogFooter = components.AlertDialogFooter || defaultComponents.AlertDialogFooter;
|
|
391
|
+
const AlertDialogCancel = components.AlertDialogCancel || defaultComponents.AlertDialogCancel;
|
|
392
|
+
const AlertDialogAction = components.AlertDialogAction || defaultComponents.AlertDialogAction;
|
|
393
|
+
|
|
394
|
+
// State
|
|
395
|
+
const [roles, setRoles] = useState<TRole[]>([]);
|
|
396
|
+
const [templates, setTemplates] = useState<PolicyTemplateItem[]>([]);
|
|
397
|
+
const [users, setUsers] = useState<any[]>([]);
|
|
398
|
+
const [pendingApprovals, setPendingApprovals] = useState<any[]>([]);
|
|
399
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
400
|
+
const [isFetching, setIsFetching] = useState(false);
|
|
401
|
+
const [search, setSearch] = useState("");
|
|
402
|
+
|
|
403
|
+
// Dialog state
|
|
404
|
+
const [editingRole, setEditingRole] = useState<TRole | null>(null);
|
|
405
|
+
const [creatingRole, setCreatingRole] = useState(false);
|
|
406
|
+
const [deletingRole, setDeletingRole] = useState<TRole | null>(null);
|
|
407
|
+
const [createAsSshRole, setCreateAsSshRole] = useState(true);
|
|
408
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
409
|
+
const [isCreatingPolicy, setIsCreatingPolicy] = useState(false);
|
|
410
|
+
// Role type selection (only relevant when roleType is 'all')
|
|
411
|
+
const [createRoleType, setCreateRoleType] = useState<"realm" | "client">(
|
|
412
|
+
roleType === "client" ? "client" : "realm"
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
// Form state
|
|
416
|
+
const [formData, setFormData] = useState({ name: "", description: "" });
|
|
417
|
+
const [policyConfig, setPolicyConfig] = useState<PolicyConfig>(defaultPolicyConfig);
|
|
418
|
+
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
|
419
|
+
const [templateParams, setTemplateParams] = useState<Record<string, any>>({});
|
|
420
|
+
|
|
421
|
+
// Edit policy state
|
|
422
|
+
const [editingPolicyConfig, setEditingPolicyConfig] = useState<PolicyConfig | null>(null);
|
|
423
|
+
const [loadingPolicy, setLoadingPolicy] = useState(false);
|
|
424
|
+
const [isUpdatingPolicy, setIsUpdatingPolicy] = useState(false);
|
|
425
|
+
const [editSelectedTemplateId, setEditSelectedTemplateId] = useState<string | null>(null);
|
|
426
|
+
const [editTemplateParams, setEditTemplateParams] = useState<Record<string, any>>({});
|
|
427
|
+
|
|
428
|
+
// Fetch data
|
|
429
|
+
const fetchData = useCallback(async () => {
|
|
430
|
+
setIsFetching(true);
|
|
431
|
+
try {
|
|
432
|
+
const rolesResult = await fetchRoles();
|
|
433
|
+
const rolesList = Array.isArray(rolesResult) ? rolesResult : rolesResult.roles || [];
|
|
434
|
+
setRoles(rolesList);
|
|
435
|
+
|
|
436
|
+
if (fetchTemplates) {
|
|
437
|
+
const templatesResult = await fetchTemplates();
|
|
438
|
+
const templatesList = Array.isArray(templatesResult) ? templatesResult : templatesResult.templates || [];
|
|
439
|
+
setTemplates(templatesList);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (fetchUsers) {
|
|
443
|
+
const usersResult = await fetchUsers();
|
|
444
|
+
setUsers(usersResult);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (fetchPendingApprovals) {
|
|
448
|
+
const approvalsResult = await fetchPendingApprovals();
|
|
449
|
+
setPendingApprovals(approvalsResult);
|
|
450
|
+
}
|
|
451
|
+
} catch (error) {
|
|
452
|
+
console.error("Failed to fetch roles data:", error);
|
|
453
|
+
toast?.({
|
|
454
|
+
title: "Failed to fetch data",
|
|
455
|
+
description: error instanceof Error ? error.message : "Unknown error",
|
|
456
|
+
variant: "destructive",
|
|
457
|
+
});
|
|
458
|
+
} finally {
|
|
459
|
+
setIsLoading(false);
|
|
460
|
+
setIsFetching(false);
|
|
461
|
+
}
|
|
462
|
+
}, [fetchRoles, fetchTemplates, fetchUsers, fetchPendingApprovals, toast]);
|
|
463
|
+
|
|
464
|
+
// Initial fetch (only when API is ready)
|
|
465
|
+
React.useEffect(() => {
|
|
466
|
+
if (isReady) {
|
|
467
|
+
void fetchData();
|
|
468
|
+
}
|
|
469
|
+
}, [fetchData, isReady]);
|
|
470
|
+
|
|
471
|
+
// Auto refresh (blocked until API is ready)
|
|
472
|
+
const { secondsRemaining, refreshNow } = useAutoRefresh({
|
|
473
|
+
intervalSeconds: refreshInterval,
|
|
474
|
+
refresh: fetchData,
|
|
475
|
+
isBlocked: !isReady || isFetching,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Calculate threshold
|
|
479
|
+
const calculatedThreshold = useMemo(() => {
|
|
480
|
+
if (calculateThreshold) {
|
|
481
|
+
return calculateThreshold(users, pendingApprovals);
|
|
482
|
+
}
|
|
483
|
+
// Default: 70% of active admins
|
|
484
|
+
const activeAdminCount = users.filter((u) => u.enabled && u.linked && u.isAdmin).length;
|
|
485
|
+
return Math.max(1, Math.floor(activeAdminCount * 0.7));
|
|
486
|
+
}, [users, pendingApprovals, calculateThreshold]);
|
|
487
|
+
|
|
488
|
+
const activeAdminCount = useMemo(() => {
|
|
489
|
+
return users.filter((u) => u.enabled && u.linked && u.isAdmin).length;
|
|
490
|
+
}, [users]);
|
|
491
|
+
|
|
492
|
+
// Get selected template
|
|
493
|
+
const selectedTemplate = selectedTemplateId
|
|
494
|
+
? templates.find((t) => t.id === selectedTemplateId)
|
|
495
|
+
: null;
|
|
496
|
+
|
|
497
|
+
// SSH role count
|
|
498
|
+
const specialRoleCount = useMemo(
|
|
499
|
+
() => roles.filter((r) => isSpecialRole(r)).length,
|
|
500
|
+
[roles, isSpecialRole]
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
// Filter roles
|
|
504
|
+
const filteredRoles = useMemo(() => {
|
|
505
|
+
return roles.filter(
|
|
506
|
+
(role) =>
|
|
507
|
+
role.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
508
|
+
(role.description?.toLowerCase().includes(search.toLowerCase()) ?? false)
|
|
509
|
+
);
|
|
510
|
+
}, [roles, search]);
|
|
511
|
+
|
|
512
|
+
// Handlers
|
|
513
|
+
const handleCreate = () => {
|
|
514
|
+
setFormData({ name: "", description: "" });
|
|
515
|
+
setCreateAsSshRole(true);
|
|
516
|
+
setPolicyConfig(defaultPolicyConfig);
|
|
517
|
+
setSelectedTemplateId(null);
|
|
518
|
+
setTemplateParams({});
|
|
519
|
+
setCreateRoleType(roleType === "client" ? "client" : "realm");
|
|
520
|
+
setCreatingRole(true);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const handleEdit = async (role: TRole) => {
|
|
524
|
+
setEditingRole(role);
|
|
525
|
+
setFormData({
|
|
526
|
+
name: role.name,
|
|
527
|
+
description: role.description || "",
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// If SSH role, fetch current policy
|
|
531
|
+
if (isSpecialRole(role) && fetchRolePolicy) {
|
|
532
|
+
setLoadingPolicy(true);
|
|
533
|
+
setEditingPolicyConfig(null);
|
|
534
|
+
setEditSelectedTemplateId(null);
|
|
535
|
+
setEditTemplateParams({});
|
|
536
|
+
try {
|
|
537
|
+
const { policy } = await fetchRolePolicy(role.name);
|
|
538
|
+
if (policy) {
|
|
539
|
+
setEditingPolicyConfig({
|
|
540
|
+
enabled: true,
|
|
541
|
+
contractType: policy.contractType || defaultPolicyConfig.contractType,
|
|
542
|
+
approvalType: policy.approvalType || "implicit",
|
|
543
|
+
executionType: policy.executionType || "private",
|
|
544
|
+
threshold: policy.threshold || 1,
|
|
545
|
+
});
|
|
546
|
+
} else {
|
|
547
|
+
setEditingPolicyConfig({ ...defaultPolicyConfig, enabled: false });
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
setEditingPolicyConfig({ ...defaultPolicyConfig, enabled: false });
|
|
551
|
+
} finally {
|
|
552
|
+
setLoadingPolicy(false);
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
setEditingPolicyConfig(null);
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const handleCreateSubmit = async (e: React.FormEvent) => {
|
|
560
|
+
e.preventDefault();
|
|
561
|
+
const name = createAsSshRole ? normalizeSpecialRoleName(formData.name) : formData.name.trim();
|
|
562
|
+
if (!name) {
|
|
563
|
+
toast?.({ title: "Role name is required", variant: "destructive" });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
setIsSubmitting(true);
|
|
568
|
+
try {
|
|
569
|
+
await onCreate({
|
|
570
|
+
name,
|
|
571
|
+
description: formData.description || undefined,
|
|
572
|
+
policy: policyConfig.enabled ? policyConfig : undefined,
|
|
573
|
+
roleType: roleType === "all" ? createRoleType : (roleType === "client" ? "client" : "realm"),
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Create policy if enabled
|
|
577
|
+
if (policyConfig.enabled && onCreatePolicy) {
|
|
578
|
+
setIsCreatingPolicy(true);
|
|
579
|
+
try {
|
|
580
|
+
await onCreatePolicy({
|
|
581
|
+
roleName: name,
|
|
582
|
+
policyConfig,
|
|
583
|
+
templateId: selectedTemplateId || undefined,
|
|
584
|
+
templateParams,
|
|
585
|
+
threshold: calculatedThreshold,
|
|
586
|
+
});
|
|
587
|
+
toast?.({ title: "Policy request created", description: "Pending admin approval" });
|
|
588
|
+
} catch (error) {
|
|
589
|
+
console.error("Failed to create policy request:", error);
|
|
590
|
+
toast?.({
|
|
591
|
+
title: "Policy creation failed",
|
|
592
|
+
description: error instanceof Error ? error.message : "Unknown error",
|
|
593
|
+
variant: "destructive",
|
|
594
|
+
});
|
|
595
|
+
} finally {
|
|
596
|
+
setIsCreatingPolicy(false);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (invalidateQueries && queryKeys.length > 0) {
|
|
601
|
+
invalidateQueries(queryKeys);
|
|
602
|
+
}
|
|
603
|
+
await fetchData();
|
|
604
|
+
setCreatingRole(false);
|
|
605
|
+
setFormData({ name: "", description: "" });
|
|
606
|
+
setPolicyConfig(defaultPolicyConfig);
|
|
607
|
+
setSelectedTemplateId(null);
|
|
608
|
+
setTemplateParams({});
|
|
609
|
+
toast?.({ title: "Role created successfully" });
|
|
610
|
+
} catch (error) {
|
|
611
|
+
toast?.({
|
|
612
|
+
title: "Failed to create role",
|
|
613
|
+
description: error instanceof Error ? error.message : "Unknown error",
|
|
614
|
+
variant: "destructive",
|
|
615
|
+
});
|
|
616
|
+
} finally {
|
|
617
|
+
setIsSubmitting(false);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const handleEditSubmit = async (e: React.FormEvent) => {
|
|
622
|
+
e.preventDefault();
|
|
623
|
+
if (!editingRole) return;
|
|
624
|
+
|
|
625
|
+
setIsSubmitting(true);
|
|
626
|
+
try {
|
|
627
|
+
await onUpdate({
|
|
628
|
+
name: formData.name,
|
|
629
|
+
description: formData.description || undefined,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Update policy if changed for SSH role
|
|
633
|
+
if (
|
|
634
|
+
editingPolicyConfig &&
|
|
635
|
+
editingPolicyConfig.enabled &&
|
|
636
|
+
isSpecialRole(editingRole) &&
|
|
637
|
+
onCreatePolicy
|
|
638
|
+
) {
|
|
639
|
+
setIsUpdatingPolicy(true);
|
|
640
|
+
try {
|
|
641
|
+
await onCreatePolicy({
|
|
642
|
+
roleName: editingRole.name,
|
|
643
|
+
policyConfig: editingPolicyConfig,
|
|
644
|
+
templateId: editSelectedTemplateId || undefined,
|
|
645
|
+
templateParams: editTemplateParams,
|
|
646
|
+
threshold: calculatedThreshold,
|
|
647
|
+
});
|
|
648
|
+
toast?.({ title: "Policy update request created", description: "Pending admin approval" });
|
|
649
|
+
} catch (error) {
|
|
650
|
+
console.error("Failed to create policy update request:", error);
|
|
651
|
+
toast?.({
|
|
652
|
+
title: "Policy update failed",
|
|
653
|
+
description: error instanceof Error ? error.message : "Unknown error",
|
|
654
|
+
variant: "destructive",
|
|
655
|
+
});
|
|
656
|
+
} finally {
|
|
657
|
+
setIsUpdatingPolicy(false);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (invalidateQueries && queryKeys.length > 0) {
|
|
662
|
+
invalidateQueries(queryKeys);
|
|
663
|
+
}
|
|
664
|
+
await fetchData();
|
|
665
|
+
setEditingRole(null);
|
|
666
|
+
toast?.({ title: "Role updated successfully" });
|
|
667
|
+
} catch (error) {
|
|
668
|
+
toast?.({
|
|
669
|
+
title: "Failed to update role",
|
|
670
|
+
description: error instanceof Error ? error.message : "Unknown error",
|
|
671
|
+
variant: "destructive",
|
|
672
|
+
});
|
|
673
|
+
} finally {
|
|
674
|
+
setIsSubmitting(false);
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const handleDeleteConfirm = async () => {
|
|
679
|
+
if (!deletingRole) return;
|
|
680
|
+
|
|
681
|
+
setIsSubmitting(true);
|
|
682
|
+
try {
|
|
683
|
+
const result = await onDelete(deletingRole.name);
|
|
684
|
+
if (invalidateQueries && queryKeys.length > 0) {
|
|
685
|
+
invalidateQueries(queryKeys);
|
|
686
|
+
}
|
|
687
|
+
await fetchData();
|
|
688
|
+
setDeletingRole(null);
|
|
689
|
+
setEditingRole(null);
|
|
690
|
+
|
|
691
|
+
if (result?.approvalCreated) {
|
|
692
|
+
toast?.({
|
|
693
|
+
title: "Approval request created",
|
|
694
|
+
description: "Role has users assigned. An approval request has been created for review.",
|
|
695
|
+
});
|
|
696
|
+
} else {
|
|
697
|
+
toast?.({ title: "Role deleted successfully" });
|
|
698
|
+
}
|
|
699
|
+
} catch (error) {
|
|
700
|
+
toast?.({
|
|
701
|
+
title: "Failed to delete role",
|
|
702
|
+
description: error instanceof Error ? error.message : "Unknown error",
|
|
703
|
+
variant: "destructive",
|
|
704
|
+
});
|
|
705
|
+
} finally {
|
|
706
|
+
setIsSubmitting(false);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// Template selection helper
|
|
711
|
+
const handleTemplateSelect = (
|
|
712
|
+
templateId: string | null,
|
|
713
|
+
setTemplateIdFn: (id: string | null) => void,
|
|
714
|
+
setParamsFn: (params: Record<string, any>) => void
|
|
715
|
+
) => {
|
|
716
|
+
if (!templateId || templateId === "default") {
|
|
717
|
+
setTemplateIdFn(null);
|
|
718
|
+
setParamsFn({});
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
setTemplateIdFn(templateId);
|
|
723
|
+
const template = templates.find((t) => t.id === templateId);
|
|
724
|
+
if (template) {
|
|
725
|
+
const defaults: Record<string, any> = {};
|
|
726
|
+
template.parameters.forEach((p) => {
|
|
727
|
+
if (p.defaultValue !== undefined) {
|
|
728
|
+
defaults[p.name] = p.defaultValue;
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
setParamsFn(defaults);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
// Render template parameters
|
|
736
|
+
const renderTemplateParams = (
|
|
737
|
+
template: PolicyTemplateItem | null,
|
|
738
|
+
params: Record<string, any>,
|
|
739
|
+
setParams: (params: Record<string, any>) => void
|
|
740
|
+
) => {
|
|
741
|
+
if (!template || template.parameters.length === 0) return null;
|
|
742
|
+
|
|
743
|
+
return (
|
|
744
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", padding: "0.75rem", border: "1px solid #e5e7eb", borderRadius: "0.375rem", backgroundColor: "#f9fafb" }}>
|
|
745
|
+
<p style={{ fontSize: "0.75rem", fontWeight: 500 }}>Template Parameters</p>
|
|
746
|
+
{template.parameters.map((param) => (
|
|
747
|
+
<div key={param.name} style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
748
|
+
<Label style={{ fontSize: "0.75rem" }}>
|
|
749
|
+
{param.name}
|
|
750
|
+
{param.required && <span style={{ color: "#ef4444", marginLeft: "0.25rem" }}>*</span>}
|
|
751
|
+
</Label>
|
|
752
|
+
{param.helpText && (
|
|
753
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>{param.helpText}</p>
|
|
754
|
+
)}
|
|
755
|
+
{param.type === "string" && (
|
|
756
|
+
<Input
|
|
757
|
+
value={params[param.name] || ""}
|
|
758
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
759
|
+
setParams({ ...params, [param.name]: e.target.value })
|
|
760
|
+
}
|
|
761
|
+
placeholder={param.defaultValue?.toString() || ""}
|
|
762
|
+
required={param.required}
|
|
763
|
+
style={{ height: "2rem", fontSize: "0.875rem" }}
|
|
764
|
+
/>
|
|
765
|
+
)}
|
|
766
|
+
{param.type === "number" && (
|
|
767
|
+
<Input
|
|
768
|
+
type="number"
|
|
769
|
+
value={params[param.name] || ""}
|
|
770
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
771
|
+
setParams({ ...params, [param.name]: parseInt(e.target.value) || 0 })
|
|
772
|
+
}
|
|
773
|
+
placeholder={param.defaultValue?.toString() || ""}
|
|
774
|
+
required={param.required}
|
|
775
|
+
style={{ height: "2rem", fontSize: "0.875rem" }}
|
|
776
|
+
/>
|
|
777
|
+
)}
|
|
778
|
+
{param.type === "boolean" && (
|
|
779
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
780
|
+
<Checkbox
|
|
781
|
+
checked={params[param.name] || false}
|
|
782
|
+
onCheckedChange={(v: boolean) =>
|
|
783
|
+
setParams({ ...params, [param.name]: Boolean(v) })
|
|
784
|
+
}
|
|
785
|
+
/>
|
|
786
|
+
<span style={{ fontSize: "0.75rem" }}>{params[param.name] ? "Yes" : "No"}</span>
|
|
787
|
+
</div>
|
|
788
|
+
)}
|
|
789
|
+
{param.type === "select" && (
|
|
790
|
+
<Select
|
|
791
|
+
value={params[param.name] || param.defaultValue?.toString() || ""}
|
|
792
|
+
onValueChange={(v: string) => setParams({ ...params, [param.name]: v })}
|
|
793
|
+
>
|
|
794
|
+
<SelectTrigger style={{ height: "2rem", fontSize: "0.875rem" }}>
|
|
795
|
+
<SelectValue />
|
|
796
|
+
</SelectTrigger>
|
|
797
|
+
<SelectContent>
|
|
798
|
+
{param.options?.map((opt) => (
|
|
799
|
+
<SelectItem key={opt} value={opt}>
|
|
800
|
+
{opt}
|
|
801
|
+
</SelectItem>
|
|
802
|
+
))}
|
|
803
|
+
</SelectContent>
|
|
804
|
+
</Select>
|
|
805
|
+
)}
|
|
806
|
+
</div>
|
|
807
|
+
))}
|
|
808
|
+
</div>
|
|
809
|
+
);
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// Default columns
|
|
813
|
+
const defaultColumns: ColumnDef<TRole>[] = [
|
|
814
|
+
{
|
|
815
|
+
key: "name",
|
|
816
|
+
header: "Role Name",
|
|
817
|
+
cell: (role) => (
|
|
818
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
819
|
+
<div style={{ display: "flex", height: "2.25rem", width: "2.25rem", alignItems: "center", justifyContent: "center", borderRadius: "9999px", backgroundColor: "rgba(59, 130, 246, 0.1)" }}>
|
|
820
|
+
<KeyRound style={{ height: "1rem", width: "1rem", color: "#3b82f6" }} />
|
|
821
|
+
</div>
|
|
822
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
823
|
+
<p style={{ fontWeight: 500 }}>{role.name}</p>
|
|
824
|
+
{isSpecialRole(role) && (
|
|
825
|
+
<Badge variant="outline" style={{ fontSize: "0.75rem" }}>
|
|
826
|
+
{labels.specialRoleBadge}
|
|
827
|
+
</Badge>
|
|
828
|
+
)}
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
),
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
key: "description",
|
|
835
|
+
header: "Description",
|
|
836
|
+
responsive: "sm",
|
|
837
|
+
cell: (role) => (
|
|
838
|
+
<p style={{ fontSize: "0.875rem", color: "#6b7280" }}>
|
|
839
|
+
{role.description || labels.noDescriptionText}
|
|
840
|
+
</p>
|
|
841
|
+
),
|
|
842
|
+
},
|
|
843
|
+
{
|
|
844
|
+
key: "type",
|
|
845
|
+
header: "Type",
|
|
846
|
+
responsive: "md",
|
|
847
|
+
cell: (role) => (
|
|
848
|
+
<Badge variant={role.clientRole ? "secondary" : "default"}>
|
|
849
|
+
{role.clientRole ? labels.clientRoleLabel : labels.realmRoleLabel}
|
|
850
|
+
</Badge>
|
|
851
|
+
),
|
|
852
|
+
},
|
|
853
|
+
];
|
|
854
|
+
|
|
855
|
+
const tableColumns = columns || defaultColumns;
|
|
856
|
+
|
|
857
|
+
return (
|
|
858
|
+
<div style={{ padding: "1.5rem" }} className={className}>
|
|
859
|
+
{/* Header */}
|
|
860
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.5rem", flexWrap: "wrap", gap: "1rem" }}>
|
|
861
|
+
<div>
|
|
862
|
+
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, display: "flex", alignItems: "center", gap: "0.5rem", margin: 0 }}>
|
|
863
|
+
<KeyRound style={{ width: "1.5rem", height: "1.5rem" }} />
|
|
864
|
+
{title}
|
|
865
|
+
</h1>
|
|
866
|
+
<p style={{ fontSize: "0.875rem", color: "#6b7280", margin: "0.25rem 0 0" }}>{description}</p>
|
|
867
|
+
{helpText && <div style={{ marginTop: "0.5rem" }}>{helpText}</div>}
|
|
868
|
+
{labels.specialRoleInfo && (
|
|
869
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280", margin: "0.25rem 0 0" }}>
|
|
870
|
+
{labels.specialRoleInfo} — {specialRoleCount} configured.
|
|
871
|
+
</p>
|
|
872
|
+
)}
|
|
873
|
+
</div>
|
|
874
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
875
|
+
<RefreshButton
|
|
876
|
+
onClick={() => void refreshNow()}
|
|
877
|
+
isRefreshing={isFetching}
|
|
878
|
+
secondsRemaining={secondsRemaining}
|
|
879
|
+
title="Refresh now"
|
|
880
|
+
/>
|
|
881
|
+
<Button onClick={handleCreate} title={labels.addRoleButton}>
|
|
882
|
+
<Plus style={{ width: "1rem", height: "1rem", marginRight: "0.5rem" }} />
|
|
883
|
+
{labels.addRoleButton}
|
|
884
|
+
</Button>
|
|
885
|
+
</div>
|
|
886
|
+
</div>
|
|
887
|
+
|
|
888
|
+
{/* Roles Table */}
|
|
889
|
+
<Card>
|
|
890
|
+
<div style={{ padding: "1rem", borderBottom: "1px solid #e5e7eb" }}>
|
|
891
|
+
<div style={{ position: "relative", maxWidth: "20rem" }}>
|
|
892
|
+
<Search style={{ position: "absolute", left: "0.75rem", top: "50%", transform: "translateY(-50%)", width: "1rem", height: "1rem", color: "#9ca3af" }} />
|
|
893
|
+
<Input
|
|
894
|
+
placeholder={labels.searchPlaceholder}
|
|
895
|
+
value={search}
|
|
896
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
|
897
|
+
style={{ paddingLeft: "2.25rem" }}
|
|
898
|
+
/>
|
|
899
|
+
</div>
|
|
900
|
+
</div>
|
|
901
|
+
<CardContent style={{ padding: 0 }}>
|
|
902
|
+
{isLoading ? (
|
|
903
|
+
<div style={{ padding: "1rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
904
|
+
{[1, 2, 3].map((i) => (
|
|
905
|
+
<div key={i} style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
|
906
|
+
<Skeleton style={{ height: "2.5rem", width: "2.5rem", borderRadius: "9999px" }} />
|
|
907
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
908
|
+
<Skeleton style={{ height: "1rem", width: "8rem" }} />
|
|
909
|
+
<Skeleton style={{ height: "0.75rem", width: "12rem" }} />
|
|
910
|
+
</div>
|
|
911
|
+
<Skeleton style={{ height: "1.5rem", width: "4rem" }} />
|
|
912
|
+
</div>
|
|
913
|
+
))}
|
|
914
|
+
</div>
|
|
915
|
+
) : filteredRoles.length > 0 ? (
|
|
916
|
+
<Table>
|
|
917
|
+
<TableHeader>
|
|
918
|
+
<TableRow>
|
|
919
|
+
{tableColumns.map((col) => (
|
|
920
|
+
<TableHead key={col.key}>
|
|
921
|
+
{col.header}
|
|
922
|
+
</TableHead>
|
|
923
|
+
))}
|
|
924
|
+
<TableHead style={{ textAlign: "right" }}>Actions</TableHead>
|
|
925
|
+
</TableRow>
|
|
926
|
+
</TableHeader>
|
|
927
|
+
<TableBody>
|
|
928
|
+
{filteredRoles.map((role, rowIndex) => (
|
|
929
|
+
<TableRow key={role.id}>
|
|
930
|
+
{tableColumns.map((col) => (
|
|
931
|
+
<TableCell key={col.key}>
|
|
932
|
+
{col.cell(role, rowIndex)}
|
|
933
|
+
</TableCell>
|
|
934
|
+
))}
|
|
935
|
+
<TableCell style={{ textAlign: "right" }}>
|
|
936
|
+
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
937
|
+
<Button
|
|
938
|
+
size="icon"
|
|
939
|
+
variant="ghost"
|
|
940
|
+
onClick={() => void handleEdit(role)}
|
|
941
|
+
>
|
|
942
|
+
<Pencil style={{ width: "1rem", height: "1rem" }} />
|
|
943
|
+
</Button>
|
|
944
|
+
<Button
|
|
945
|
+
size="icon"
|
|
946
|
+
variant="ghost"
|
|
947
|
+
onClick={() => setDeletingRole(role)}
|
|
948
|
+
>
|
|
949
|
+
<Trash2 style={{ width: "1rem", height: "1rem" }} />
|
|
950
|
+
</Button>
|
|
951
|
+
</div>
|
|
952
|
+
</TableCell>
|
|
953
|
+
</TableRow>
|
|
954
|
+
))}
|
|
955
|
+
</TableBody>
|
|
956
|
+
</Table>
|
|
957
|
+
) : (
|
|
958
|
+
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "3rem", textAlign: "center" }}>
|
|
959
|
+
<KeyRound style={{ width: "3rem", height: "3rem", color: "#9ca3af", marginBottom: "1rem" }} />
|
|
960
|
+
<h3 style={{ fontWeight: 500, margin: 0 }}>{labels.noRolesTitle}</h3>
|
|
961
|
+
<p style={{ fontSize: "0.875rem", color: "#6b7280", marginTop: "0.25rem" }}>
|
|
962
|
+
{search ? labels.noSearchResultsMessage : labels.noRolesDescription}
|
|
963
|
+
</p>
|
|
964
|
+
</div>
|
|
965
|
+
)}
|
|
966
|
+
</CardContent>
|
|
967
|
+
</Card>
|
|
968
|
+
|
|
969
|
+
{/* Create Role Dialog */}
|
|
970
|
+
<Dialog open={creatingRole} onOpenChange={(open) => !open && setCreatingRole(false)}>
|
|
971
|
+
<DialogContent style={{ maxWidth: "28rem", maxHeight: "90vh", overflow: "auto" }}>
|
|
972
|
+
<DialogHeader>
|
|
973
|
+
<DialogTitle>{labels.addRoleTitle}</DialogTitle>
|
|
974
|
+
<DialogDescription>{labels.createRoleDescription}</DialogDescription>
|
|
975
|
+
</DialogHeader>
|
|
976
|
+
<form onSubmit={handleCreateSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
977
|
+
{/* Role Type Selection - only show when 'all' is configured */}
|
|
978
|
+
{roleType === "all" && (
|
|
979
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
980
|
+
<Label>Role Type</Label>
|
|
981
|
+
<div style={{ display: "flex", gap: "1rem" }}>
|
|
982
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
983
|
+
<input
|
|
984
|
+
type="radio"
|
|
985
|
+
id="roleTypeRealm"
|
|
986
|
+
name="roleType"
|
|
987
|
+
checked={createRoleType === "realm"}
|
|
988
|
+
onChange={() => setCreateRoleType("realm")}
|
|
989
|
+
style={{ width: "1rem", height: "1rem" }}
|
|
990
|
+
/>
|
|
991
|
+
<Label htmlFor="roleTypeRealm" style={{ fontSize: "0.875rem", fontWeight: 400, cursor: "pointer" }}>
|
|
992
|
+
{labels.realmRoleLabel}
|
|
993
|
+
</Label>
|
|
994
|
+
</div>
|
|
995
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
996
|
+
<input
|
|
997
|
+
type="radio"
|
|
998
|
+
id="roleTypeClient"
|
|
999
|
+
name="roleType"
|
|
1000
|
+
checked={createRoleType === "client"}
|
|
1001
|
+
onChange={() => setCreateRoleType("client")}
|
|
1002
|
+
style={{ width: "1rem", height: "1rem" }}
|
|
1003
|
+
/>
|
|
1004
|
+
<Label htmlFor="roleTypeClient" style={{ fontSize: "0.875rem", fontWeight: 400, cursor: "pointer" }}>
|
|
1005
|
+
{labels.clientRoleLabel}
|
|
1006
|
+
</Label>
|
|
1007
|
+
</div>
|
|
1008
|
+
</div>
|
|
1009
|
+
</div>
|
|
1010
|
+
)}
|
|
1011
|
+
|
|
1012
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1013
|
+
<Label htmlFor="roleName">Role Name</Label>
|
|
1014
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
1015
|
+
<Checkbox
|
|
1016
|
+
id="createAsSshRole"
|
|
1017
|
+
checked={createAsSshRole}
|
|
1018
|
+
onCheckedChange={(v: boolean) => setCreateAsSshRole(Boolean(v))}
|
|
1019
|
+
/>
|
|
1020
|
+
<Label htmlFor="createAsSshRole" style={{ fontSize: "0.875rem", fontWeight: 400 }}>
|
|
1021
|
+
{labels.specialRoleCheckbox}
|
|
1022
|
+
</Label>
|
|
1023
|
+
</div>
|
|
1024
|
+
<Input
|
|
1025
|
+
id="roleName"
|
|
1026
|
+
value={formData.name}
|
|
1027
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
1028
|
+
const raw = e.target.value;
|
|
1029
|
+
setFormData({
|
|
1030
|
+
...formData,
|
|
1031
|
+
name: createAsSshRole ? normalizeSpecialRoleName(raw) : raw,
|
|
1032
|
+
});
|
|
1033
|
+
}}
|
|
1034
|
+
placeholder={createAsSshRole ? labels.specialRoleNamePlaceholder : labels.roleNamePlaceholder}
|
|
1035
|
+
required
|
|
1036
|
+
/>
|
|
1037
|
+
{createAsSshRole && labels.specialRoleHint && (
|
|
1038
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
1039
|
+
{labels.specialRoleHint}
|
|
1040
|
+
</p>
|
|
1041
|
+
)}
|
|
1042
|
+
</div>
|
|
1043
|
+
|
|
1044
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1045
|
+
<Label htmlFor="roleDescription">Description</Label>
|
|
1046
|
+
<Textarea
|
|
1047
|
+
id="roleDescription"
|
|
1048
|
+
value={formData.description}
|
|
1049
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
1050
|
+
setFormData({ ...formData, description: e.target.value })
|
|
1051
|
+
}
|
|
1052
|
+
placeholder={labels.descriptionPlaceholder}
|
|
1053
|
+
rows={3}
|
|
1054
|
+
/>
|
|
1055
|
+
</div>
|
|
1056
|
+
|
|
1057
|
+
{/* Policy Configuration - available when onCreatePolicy is provided */}
|
|
1058
|
+
{onCreatePolicy && (
|
|
1059
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "1rem", paddingTop: "1rem", borderTop: "1px solid #e5e7eb" }}>
|
|
1060
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
1061
|
+
<Shield style={{ width: "1rem", height: "1rem", color: "#6b7280" }} />
|
|
1062
|
+
<h4 style={{ fontWeight: 500 }}>{labels.policySectionTitle}</h4>
|
|
1063
|
+
</div>
|
|
1064
|
+
{labels.policySectionDescription && (
|
|
1065
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
1066
|
+
{labels.policySectionDescription}
|
|
1067
|
+
</p>
|
|
1068
|
+
)}
|
|
1069
|
+
|
|
1070
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
1071
|
+
<Checkbox
|
|
1072
|
+
id="policyEnabled"
|
|
1073
|
+
checked={policyConfig.enabled}
|
|
1074
|
+
onCheckedChange={(v: boolean) =>
|
|
1075
|
+
setPolicyConfig({ ...policyConfig, enabled: Boolean(v) })
|
|
1076
|
+
}
|
|
1077
|
+
/>
|
|
1078
|
+
<Label htmlFor="policyEnabled" style={{ fontSize: "0.875rem", fontWeight: 400 }}>
|
|
1079
|
+
{labels.createPolicyCheckbox}
|
|
1080
|
+
</Label>
|
|
1081
|
+
</div>
|
|
1082
|
+
|
|
1083
|
+
{policyConfig.enabled && (
|
|
1084
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "1rem", paddingLeft: "1.5rem" }}>
|
|
1085
|
+
{/* Template Selection */}
|
|
1086
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1087
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
1088
|
+
<FileCode style={{ width: "1rem", height: "1rem", color: "#6b7280" }} />
|
|
1089
|
+
<Label htmlFor="templateSelect">{labels.policyTemplateLabel}</Label>
|
|
1090
|
+
</div>
|
|
1091
|
+
{templates.length > 0 ? (
|
|
1092
|
+
<>
|
|
1093
|
+
<Select
|
|
1094
|
+
value={selectedTemplateId || ""}
|
|
1095
|
+
onValueChange={(v: string) =>
|
|
1096
|
+
handleTemplateSelect(
|
|
1097
|
+
v || null,
|
|
1098
|
+
setSelectedTemplateId,
|
|
1099
|
+
setTemplateParams
|
|
1100
|
+
)
|
|
1101
|
+
}
|
|
1102
|
+
>
|
|
1103
|
+
<SelectTrigger>
|
|
1104
|
+
<SelectValue placeholder="Select a template..." />
|
|
1105
|
+
</SelectTrigger>
|
|
1106
|
+
<SelectContent>
|
|
1107
|
+
{templates.map((t) => (
|
|
1108
|
+
<SelectItem key={t.id} value={t.id}>
|
|
1109
|
+
{t.name}
|
|
1110
|
+
</SelectItem>
|
|
1111
|
+
))}
|
|
1112
|
+
</SelectContent>
|
|
1113
|
+
</Select>
|
|
1114
|
+
{selectedTemplate && (
|
|
1115
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
1116
|
+
{selectedTemplate.description}
|
|
1117
|
+
</p>
|
|
1118
|
+
)}
|
|
1119
|
+
</>
|
|
1120
|
+
) : (
|
|
1121
|
+
<p style={{ fontSize: "0.875rem", color: "#6b7280", fontStyle: "italic" }}>
|
|
1122
|
+
No templates available
|
|
1123
|
+
</p>
|
|
1124
|
+
)}
|
|
1125
|
+
</div>
|
|
1126
|
+
|
|
1127
|
+
{/* Template Parameters */}
|
|
1128
|
+
{renderTemplateParams(selectedTemplate ?? null, templateParams, setTemplateParams)}
|
|
1129
|
+
|
|
1130
|
+
{/* Contract Type */}
|
|
1131
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1132
|
+
<Label htmlFor="contractType">Contract Type</Label>
|
|
1133
|
+
<Select
|
|
1134
|
+
value={policyConfig.contractType}
|
|
1135
|
+
onValueChange={(v: string) =>
|
|
1136
|
+
setPolicyConfig({ ...policyConfig, contractType: v })
|
|
1137
|
+
}
|
|
1138
|
+
>
|
|
1139
|
+
<SelectTrigger>
|
|
1140
|
+
<SelectValue />
|
|
1141
|
+
</SelectTrigger>
|
|
1142
|
+
<SelectContent>
|
|
1143
|
+
{contractTypes.map((ct) => (
|
|
1144
|
+
<SelectItem key={ct.value} value={ct.value}>
|
|
1145
|
+
{ct.label}
|
|
1146
|
+
</SelectItem>
|
|
1147
|
+
))}
|
|
1148
|
+
</SelectContent>
|
|
1149
|
+
</Select>
|
|
1150
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
1151
|
+
Contract ID: <code style={{ backgroundColor: "#f3f4f6", padding: "0 0.25rem", borderRadius: "0.25rem" }}>{policyConfig.contractType}</code>
|
|
1152
|
+
</p>
|
|
1153
|
+
</div>
|
|
1154
|
+
|
|
1155
|
+
{/* Approval & Execution Type */}
|
|
1156
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
|
|
1157
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1158
|
+
<Label htmlFor="approvalType">Approval Type</Label>
|
|
1159
|
+
<Select
|
|
1160
|
+
value={policyConfig.approvalType}
|
|
1161
|
+
onValueChange={(v: string) =>
|
|
1162
|
+
setPolicyConfig({
|
|
1163
|
+
...policyConfig,
|
|
1164
|
+
approvalType: v as "implicit" | "explicit",
|
|
1165
|
+
})
|
|
1166
|
+
}
|
|
1167
|
+
>
|
|
1168
|
+
<SelectTrigger>
|
|
1169
|
+
<SelectValue />
|
|
1170
|
+
</SelectTrigger>
|
|
1171
|
+
<SelectContent>
|
|
1172
|
+
<SelectItem value="implicit">Implicit</SelectItem>
|
|
1173
|
+
<SelectItem value="explicit">Explicit</SelectItem>
|
|
1174
|
+
</SelectContent>
|
|
1175
|
+
</Select>
|
|
1176
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
1177
|
+
{policyConfig.approvalType === "implicit"
|
|
1178
|
+
? "No manual approval required"
|
|
1179
|
+
: "Requires user approval"}
|
|
1180
|
+
</p>
|
|
1181
|
+
</div>
|
|
1182
|
+
|
|
1183
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1184
|
+
<Label htmlFor="executionType">Execution Type</Label>
|
|
1185
|
+
<Select
|
|
1186
|
+
value={policyConfig.executionType}
|
|
1187
|
+
onValueChange={(v: string) =>
|
|
1188
|
+
setPolicyConfig({
|
|
1189
|
+
...policyConfig,
|
|
1190
|
+
executionType: v as "public" | "private",
|
|
1191
|
+
})
|
|
1192
|
+
}
|
|
1193
|
+
>
|
|
1194
|
+
<SelectTrigger>
|
|
1195
|
+
<SelectValue />
|
|
1196
|
+
</SelectTrigger>
|
|
1197
|
+
<SelectContent>
|
|
1198
|
+
<SelectItem value="public">Public</SelectItem>
|
|
1199
|
+
<SelectItem value="private">Private</SelectItem>
|
|
1200
|
+
</SelectContent>
|
|
1201
|
+
</Select>
|
|
1202
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
1203
|
+
{policyConfig.executionType === "public"
|
|
1204
|
+
? "Anyone can execute"
|
|
1205
|
+
: "Role-based execution"}
|
|
1206
|
+
</p>
|
|
1207
|
+
</div>
|
|
1208
|
+
</div>
|
|
1209
|
+
|
|
1210
|
+
{/* Threshold */}
|
|
1211
|
+
{policyConfig.approvalType === "explicit" && (
|
|
1212
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1213
|
+
<Label htmlFor="threshold">Approval Threshold</Label>
|
|
1214
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem", backgroundColor: "#f3f4f6", borderRadius: "0.375rem" }}>
|
|
1215
|
+
<span style={{ fontWeight: 500 }}>{calculatedThreshold}</span>
|
|
1216
|
+
<span style={{ color: "#6b7280", fontSize: "0.875rem" }}>
|
|
1217
|
+
(70% of {activeAdminCount} active admin
|
|
1218
|
+
{activeAdminCount !== 1 ? "s" : ""})
|
|
1219
|
+
</span>
|
|
1220
|
+
</div>
|
|
1221
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
1222
|
+
Automatically calculated based on active admins
|
|
1223
|
+
</p>
|
|
1224
|
+
</div>
|
|
1225
|
+
)}
|
|
1226
|
+
</div>
|
|
1227
|
+
)}
|
|
1228
|
+
</div>
|
|
1229
|
+
)}
|
|
1230
|
+
|
|
1231
|
+
<DialogFooter>
|
|
1232
|
+
<Button type="button" variant="outline" onClick={() => setCreatingRole(false)}>
|
|
1233
|
+
{labels.cancelButton}
|
|
1234
|
+
</Button>
|
|
1235
|
+
<Button type="submit" disabled={isSubmitting || isCreatingPolicy}>
|
|
1236
|
+
{isSubmitting || isCreatingPolicy ? "Creating..." : labels.createButton}
|
|
1237
|
+
</Button>
|
|
1238
|
+
</DialogFooter>
|
|
1239
|
+
</form>
|
|
1240
|
+
</DialogContent>
|
|
1241
|
+
</Dialog>
|
|
1242
|
+
|
|
1243
|
+
{/* Edit Role Dialog */}
|
|
1244
|
+
<Dialog open={!!editingRole} onOpenChange={(open) => !open && setEditingRole(null)}>
|
|
1245
|
+
<DialogContent style={{ maxWidth: "28rem", maxHeight: "90vh", overflowY: "auto" }}>
|
|
1246
|
+
<DialogHeader>
|
|
1247
|
+
<DialogTitle>{labels.editRoleTitle}</DialogTitle>
|
|
1248
|
+
<DialogDescription>{labels.editRoleDescription}</DialogDescription>
|
|
1249
|
+
</DialogHeader>
|
|
1250
|
+
<form onSubmit={handleEditSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
1251
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1252
|
+
<Label>Role Name</Label>
|
|
1253
|
+
<Input value={formData.name} disabled style={{ backgroundColor: "#f3f4f6" }} />
|
|
1254
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>{labels.roleNameDisabledHint}</p>
|
|
1255
|
+
</div>
|
|
1256
|
+
|
|
1257
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1258
|
+
<Label>Description</Label>
|
|
1259
|
+
<Textarea
|
|
1260
|
+
value={formData.description}
|
|
1261
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
1262
|
+
setFormData({ ...formData, description: e.target.value })
|
|
1263
|
+
}
|
|
1264
|
+
placeholder={labels.descriptionPlaceholder}
|
|
1265
|
+
rows={3}
|
|
1266
|
+
/>
|
|
1267
|
+
</div>
|
|
1268
|
+
|
|
1269
|
+
{/* Policy Configuration - special roles only */}
|
|
1270
|
+
{editingRole && isSpecialRole(editingRole) && onCreatePolicy && (
|
|
1271
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "1rem", paddingTop: "1rem", borderTop: "1px solid #e5e7eb" }}>
|
|
1272
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
1273
|
+
<Shield style={{ width: "1rem", height: "1rem", color: "#6b7280" }} />
|
|
1274
|
+
<h4 style={{ fontWeight: 500 }}>{labels.policySectionTitle}</h4>
|
|
1275
|
+
</div>
|
|
1276
|
+
|
|
1277
|
+
{loadingPolicy ? (
|
|
1278
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.875rem", color: "#6b7280" }}>
|
|
1279
|
+
<div style={{ height: "1rem", width: "1rem", border: "2px solid #6b7280", borderTopColor: "transparent", borderRadius: "9999px", animation: "spin 1s linear infinite" }} />
|
|
1280
|
+
{labels.loadingPolicyText}
|
|
1281
|
+
</div>
|
|
1282
|
+
) : editingPolicyConfig ? (
|
|
1283
|
+
<>
|
|
1284
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
1285
|
+
<Checkbox
|
|
1286
|
+
id="editPolicyEnabled"
|
|
1287
|
+
checked={editingPolicyConfig.enabled}
|
|
1288
|
+
onCheckedChange={(v: boolean) =>
|
|
1289
|
+
setEditingPolicyConfig({
|
|
1290
|
+
...editingPolicyConfig,
|
|
1291
|
+
enabled: Boolean(v),
|
|
1292
|
+
})
|
|
1293
|
+
}
|
|
1294
|
+
/>
|
|
1295
|
+
<Label htmlFor="editPolicyEnabled" style={{ fontSize: "0.875rem", fontWeight: 400 }}>
|
|
1296
|
+
{editingPolicyConfig.enabled
|
|
1297
|
+
? labels.updatePolicyCheckbox
|
|
1298
|
+
: labels.createPolicyCheckbox}
|
|
1299
|
+
</Label>
|
|
1300
|
+
</div>
|
|
1301
|
+
|
|
1302
|
+
{editingPolicyConfig.enabled && (
|
|
1303
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "1rem", paddingLeft: "1.5rem" }}>
|
|
1304
|
+
{/* Template Selection */}
|
|
1305
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1306
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
1307
|
+
<FileCode style={{ width: "1rem", height: "1rem", color: "#6b7280" }} />
|
|
1308
|
+
<Label htmlFor="editTemplateSelect">{labels.policyTemplateLabel}</Label>
|
|
1309
|
+
</div>
|
|
1310
|
+
{templates.length > 0 ? (
|
|
1311
|
+
<>
|
|
1312
|
+
<Select
|
|
1313
|
+
value={editSelectedTemplateId || ""}
|
|
1314
|
+
onValueChange={(v: string) =>
|
|
1315
|
+
handleTemplateSelect(
|
|
1316
|
+
v || null,
|
|
1317
|
+
setEditSelectedTemplateId,
|
|
1318
|
+
setEditTemplateParams
|
|
1319
|
+
)
|
|
1320
|
+
}
|
|
1321
|
+
>
|
|
1322
|
+
<SelectTrigger>
|
|
1323
|
+
<SelectValue placeholder="Select a template..." />
|
|
1324
|
+
</SelectTrigger>
|
|
1325
|
+
<SelectContent>
|
|
1326
|
+
{templates.map((t) => (
|
|
1327
|
+
<SelectItem key={t.id} value={t.id}>
|
|
1328
|
+
{t.name}
|
|
1329
|
+
</SelectItem>
|
|
1330
|
+
))}
|
|
1331
|
+
</SelectContent>
|
|
1332
|
+
</Select>
|
|
1333
|
+
{editSelectedTemplateId && templates.find((t) => t.id === editSelectedTemplateId)?.description && (
|
|
1334
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
1335
|
+
{templates.find((t) => t.id === editSelectedTemplateId)?.description}
|
|
1336
|
+
</p>
|
|
1337
|
+
)}
|
|
1338
|
+
</>
|
|
1339
|
+
) : (
|
|
1340
|
+
<p style={{ fontSize: "0.875rem", color: "#6b7280", fontStyle: "italic" }}>
|
|
1341
|
+
No templates available
|
|
1342
|
+
</p>
|
|
1343
|
+
)}
|
|
1344
|
+
</div>
|
|
1345
|
+
|
|
1346
|
+
{/* Template Parameters */}
|
|
1347
|
+
{editSelectedTemplateId &&
|
|
1348
|
+
renderTemplateParams(
|
|
1349
|
+
templates.find((t) => t.id === editSelectedTemplateId) || null,
|
|
1350
|
+
editTemplateParams,
|
|
1351
|
+
setEditTemplateParams
|
|
1352
|
+
)}
|
|
1353
|
+
|
|
1354
|
+
{/* Approval & Execution Type */}
|
|
1355
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
|
|
1356
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1357
|
+
<Label>Approval Type</Label>
|
|
1358
|
+
<Select
|
|
1359
|
+
value={editingPolicyConfig.approvalType}
|
|
1360
|
+
onValueChange={(v: string) =>
|
|
1361
|
+
setEditingPolicyConfig({
|
|
1362
|
+
...editingPolicyConfig,
|
|
1363
|
+
approvalType: v as "implicit" | "explicit",
|
|
1364
|
+
})
|
|
1365
|
+
}
|
|
1366
|
+
>
|
|
1367
|
+
<SelectTrigger>
|
|
1368
|
+
<SelectValue />
|
|
1369
|
+
</SelectTrigger>
|
|
1370
|
+
<SelectContent>
|
|
1371
|
+
<SelectItem value="implicit">Implicit</SelectItem>
|
|
1372
|
+
<SelectItem value="explicit">Explicit</SelectItem>
|
|
1373
|
+
</SelectContent>
|
|
1374
|
+
</Select>
|
|
1375
|
+
</div>
|
|
1376
|
+
|
|
1377
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1378
|
+
<Label>Execution Type</Label>
|
|
1379
|
+
<Select
|
|
1380
|
+
value={editingPolicyConfig.executionType}
|
|
1381
|
+
onValueChange={(v: string) =>
|
|
1382
|
+
setEditingPolicyConfig({
|
|
1383
|
+
...editingPolicyConfig,
|
|
1384
|
+
executionType: v as "public" | "private",
|
|
1385
|
+
})
|
|
1386
|
+
}
|
|
1387
|
+
>
|
|
1388
|
+
<SelectTrigger>
|
|
1389
|
+
<SelectValue />
|
|
1390
|
+
</SelectTrigger>
|
|
1391
|
+
<SelectContent>
|
|
1392
|
+
<SelectItem value="public">Public</SelectItem>
|
|
1393
|
+
<SelectItem value="private">Private</SelectItem>
|
|
1394
|
+
</SelectContent>
|
|
1395
|
+
</Select>
|
|
1396
|
+
</div>
|
|
1397
|
+
</div>
|
|
1398
|
+
|
|
1399
|
+
{/* Threshold */}
|
|
1400
|
+
{editingPolicyConfig.approvalType === "explicit" && (
|
|
1401
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1402
|
+
<Label>Approval Threshold</Label>
|
|
1403
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem", backgroundColor: "#f3f4f6", borderRadius: "0.375rem" }}>
|
|
1404
|
+
<span style={{ fontWeight: 500 }}>{calculatedThreshold}</span>
|
|
1405
|
+
<span style={{ color: "#6b7280", fontSize: "0.875rem" }}>
|
|
1406
|
+
(70% of {activeAdminCount} active admin
|
|
1407
|
+
{activeAdminCount !== 1 ? "s" : ""})
|
|
1408
|
+
</span>
|
|
1409
|
+
</div>
|
|
1410
|
+
<p style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
1411
|
+
Automatically calculated based on active admins
|
|
1412
|
+
</p>
|
|
1413
|
+
</div>
|
|
1414
|
+
)}
|
|
1415
|
+
|
|
1416
|
+
<p style={{ fontSize: "0.75rem", backgroundColor: "#fffbeb", color: "#92400e", padding: "0.5rem", borderRadius: "0.25rem" }}>
|
|
1417
|
+
{labels.policyUpdateWarning}
|
|
1418
|
+
</p>
|
|
1419
|
+
</div>
|
|
1420
|
+
)}
|
|
1421
|
+
</>
|
|
1422
|
+
) : null}
|
|
1423
|
+
</div>
|
|
1424
|
+
)}
|
|
1425
|
+
|
|
1426
|
+
<DialogFooter style={{ display: "flex", justifyContent: "space-between" }}>
|
|
1427
|
+
<Button
|
|
1428
|
+
type="button"
|
|
1429
|
+
variant="destructive"
|
|
1430
|
+
onClick={() => editingRole && setDeletingRole(editingRole)}
|
|
1431
|
+
>
|
|
1432
|
+
<Trash2 style={{ height: "1rem", width: "1rem", marginRight: "0.5rem" }} />
|
|
1433
|
+
{labels.deleteButton}
|
|
1434
|
+
</Button>
|
|
1435
|
+
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
1436
|
+
<Button type="button" variant="outline" onClick={() => setEditingRole(null)}>
|
|
1437
|
+
{labels.cancelButton}
|
|
1438
|
+
</Button>
|
|
1439
|
+
<Button type="submit" disabled={isSubmitting || isUpdatingPolicy}>
|
|
1440
|
+
{isSubmitting || isUpdatingPolicy ? "Saving..." : labels.saveButton}
|
|
1441
|
+
</Button>
|
|
1442
|
+
</div>
|
|
1443
|
+
</DialogFooter>
|
|
1444
|
+
</form>
|
|
1445
|
+
</DialogContent>
|
|
1446
|
+
</Dialog>
|
|
1447
|
+
|
|
1448
|
+
{/* Delete Confirmation Dialog */}
|
|
1449
|
+
<AlertDialog open={!!deletingRole} onOpenChange={(open) => !open && setDeletingRole(null)}>
|
|
1450
|
+
<AlertDialogContent>
|
|
1451
|
+
<AlertDialogHeader>
|
|
1452
|
+
<AlertDialogTitle>{labels.deleteRoleTitle}</AlertDialogTitle>
|
|
1453
|
+
<AlertDialogDescription>
|
|
1454
|
+
{labels.deleteConfirmMessage.replace("{name}", deletingRole?.name || "")}
|
|
1455
|
+
</AlertDialogDescription>
|
|
1456
|
+
</AlertDialogHeader>
|
|
1457
|
+
<AlertDialogFooter>
|
|
1458
|
+
<AlertDialogCancel>{labels.cancelButton}</AlertDialogCancel>
|
|
1459
|
+
<AlertDialogAction
|
|
1460
|
+
onClick={handleDeleteConfirm}
|
|
1461
|
+
style={{ backgroundColor: "#ef4444", color: "#ffffff" }}
|
|
1462
|
+
>
|
|
1463
|
+
{isSubmitting ? "Deleting..." : labels.deleteButton}
|
|
1464
|
+
</AlertDialogAction>
|
|
1465
|
+
</AlertDialogFooter>
|
|
1466
|
+
</AlertDialogContent>
|
|
1467
|
+
</AlertDialog>
|
|
1468
|
+
</div>
|
|
1469
|
+
);
|
|
1470
|
+
}
|