@tidecloak/ui-framework 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +377 -0
  2. package/dist/index.d.mts +2739 -0
  3. package/dist/index.d.ts +2739 -0
  4. package/dist/index.js +12869 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +12703 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/package.json +54 -0
  9. package/src/components/common/ActionButton.tsx +234 -0
  10. package/src/components/common/EmptyState.tsx +140 -0
  11. package/src/components/common/LoadingSkeleton.tsx +121 -0
  12. package/src/components/common/RefreshButton.tsx +127 -0
  13. package/src/components/common/StatusBadge.tsx +177 -0
  14. package/src/components/common/index.ts +31 -0
  15. package/src/components/data-table/DataTable.tsx +201 -0
  16. package/src/components/data-table/PaginatedTable.tsx +247 -0
  17. package/src/components/data-table/index.ts +2 -0
  18. package/src/components/dialogs/CollapsibleSection.tsx +184 -0
  19. package/src/components/dialogs/ConfirmDialog.tsx +264 -0
  20. package/src/components/dialogs/DetailDialog.tsx +228 -0
  21. package/src/components/dialogs/index.ts +3 -0
  22. package/src/components/index.ts +5 -0
  23. package/src/components/pages/base/ApprovalsPageBase.tsx +680 -0
  24. package/src/components/pages/base/LogsPageBase.tsx +581 -0
  25. package/src/components/pages/base/RolesPageBase.tsx +1470 -0
  26. package/src/components/pages/base/TemplatesPageBase.tsx +761 -0
  27. package/src/components/pages/base/UsersPageBase.tsx +843 -0
  28. package/src/components/pages/base/index.ts +58 -0
  29. package/src/components/pages/connected/ApprovalsPage.tsx +797 -0
  30. package/src/components/pages/connected/LogsPage.tsx +267 -0
  31. package/src/components/pages/connected/RolesPage.tsx +525 -0
  32. package/src/components/pages/connected/TemplatesPage.tsx +181 -0
  33. package/src/components/pages/connected/UsersPage.tsx +237 -0
  34. package/src/components/pages/connected/index.ts +36 -0
  35. package/src/components/pages/index.ts +5 -0
  36. package/src/components/tabs/TabsView.tsx +300 -0
  37. package/src/components/tabs/index.ts +1 -0
  38. package/src/components/ui/index.tsx +1001 -0
  39. package/src/hooks/index.ts +3 -0
  40. package/src/hooks/useAutoRefresh.ts +119 -0
  41. package/src/hooks/usePagination.ts +152 -0
  42. package/src/hooks/useSelection.ts +81 -0
  43. package/src/index.ts +256 -0
  44. package/src/theme.ts +185 -0
  45. package/src/tide/index.ts +19 -0
  46. package/src/tide/tidePolicy.ts +270 -0
  47. package/src/types/index.ts +484 -0
  48. package/src/utils/index.ts +121 -0
@@ -0,0 +1,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
+ }