@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,680 @@
|
|
|
1
|
+
import React, { useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { CheckSquare, User, Shield, FileKey, Eye, Upload, X, Undo2, Check, Clock, CheckCircle2, XCircle, Trash2 } from "lucide-react";
|
|
3
|
+
import { formatTimestamp, delay } from "../../../utils";
|
|
4
|
+
import { useAutoRefresh } from "../../../hooks/useAutoRefresh";
|
|
5
|
+
import { useSelection } from "../../../hooks/useSelection";
|
|
6
|
+
import { RefreshButton } from "../../common/RefreshButton";
|
|
7
|
+
import { StatusBadge } from "../../common/StatusBadge";
|
|
8
|
+
import { ActionButton, ActionButtonGroup } from "../../common/ActionButton";
|
|
9
|
+
import { LoadingSkeleton } from "../../common/LoadingSkeleton";
|
|
10
|
+
import { EmptyState } from "../../common/EmptyState";
|
|
11
|
+
import { TabsView } from "../../tabs/TabsView";
|
|
12
|
+
import { DataTable } from "../../data-table/DataTable";
|
|
13
|
+
import { DetailDialog } from "../../dialogs/DetailDialog";
|
|
14
|
+
import { defaultComponents } from "../../ui";
|
|
15
|
+
import type { BaseDataItem, ColumnDef, DetailField, ChangeSetRequest, ToastConfig } from "../../../types";
|
|
16
|
+
|
|
17
|
+
export interface BaseApprovalItem extends BaseDataItem {
|
|
18
|
+
status: string;
|
|
19
|
+
timestamp: string | number;
|
|
20
|
+
retrievalInfo?: ChangeSetRequest;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AccessApprovalItem extends BaseApprovalItem {
|
|
24
|
+
username: string;
|
|
25
|
+
role: string;
|
|
26
|
+
clientId: string;
|
|
27
|
+
commitReady: boolean;
|
|
28
|
+
decisionMade: boolean;
|
|
29
|
+
rejectionFound?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RoleApprovalItem extends BaseApprovalItem {
|
|
33
|
+
role: string;
|
|
34
|
+
compositeRole?: string;
|
|
35
|
+
clientId: string;
|
|
36
|
+
requestType: string;
|
|
37
|
+
requestedBy: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PolicyApprovalItem extends BaseApprovalItem {
|
|
41
|
+
roleId: string;
|
|
42
|
+
requestedBy: string;
|
|
43
|
+
requestedByEmail?: string;
|
|
44
|
+
threshold: number;
|
|
45
|
+
approvalCount: number;
|
|
46
|
+
rejectionCount?: number;
|
|
47
|
+
commitReady?: boolean;
|
|
48
|
+
approvedBy?: string[];
|
|
49
|
+
deniedBy?: string[];
|
|
50
|
+
policyRequestData: string;
|
|
51
|
+
contractCode?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ApprovalDecision {
|
|
55
|
+
decidedBy: string;
|
|
56
|
+
decidedByEmail?: string;
|
|
57
|
+
decision: "approved" | "rejected";
|
|
58
|
+
timestamp: string | number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ApprovalTabConfig<T extends BaseApprovalItem> {
|
|
62
|
+
key: string;
|
|
63
|
+
label: string;
|
|
64
|
+
icon?: React.ReactNode;
|
|
65
|
+
/** Data fetcher */
|
|
66
|
+
fetchData: () => Promise<T[]>;
|
|
67
|
+
/** Column definitions for the table */
|
|
68
|
+
columns: ColumnDef<T>[];
|
|
69
|
+
/** Empty state configuration */
|
|
70
|
+
emptyState: {
|
|
71
|
+
icon: React.ReactNode;
|
|
72
|
+
title: string;
|
|
73
|
+
description: string;
|
|
74
|
+
};
|
|
75
|
+
/** Action handlers */
|
|
76
|
+
actions: {
|
|
77
|
+
onReview?: (item: T) => Promise<void>;
|
|
78
|
+
onCommit?: (item: T) => Promise<void>;
|
|
79
|
+
onCancel?: (item: T) => Promise<void>;
|
|
80
|
+
onRevoke?: (item: T) => Promise<void>;
|
|
81
|
+
onView?: (item: T) => void;
|
|
82
|
+
onDelete?: (item: T) => Promise<void>;
|
|
83
|
+
};
|
|
84
|
+
/** Check if item is ready to commit */
|
|
85
|
+
canCommit?: (item: T) => boolean;
|
|
86
|
+
/** Check if user has made a decision */
|
|
87
|
+
hasUserDecided?: (item: T) => boolean;
|
|
88
|
+
/** Query keys for cache invalidation */
|
|
89
|
+
queryKeys?: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ApprovalsPageBaseProps<
|
|
93
|
+
TAccess extends AccessApprovalItem = AccessApprovalItem,
|
|
94
|
+
TRole extends RoleApprovalItem = RoleApprovalItem,
|
|
95
|
+
TPolicy extends PolicyApprovalItem = PolicyApprovalItem
|
|
96
|
+
> {
|
|
97
|
+
/** Page title */
|
|
98
|
+
title?: string;
|
|
99
|
+
/** Page description */
|
|
100
|
+
description?: string;
|
|
101
|
+
/** Tab configurations for default tabs */
|
|
102
|
+
tabs: {
|
|
103
|
+
access?: ApprovalTabConfig<TAccess>;
|
|
104
|
+
roles?: ApprovalTabConfig<TRole>;
|
|
105
|
+
policies?: ApprovalTabConfig<TPolicy>;
|
|
106
|
+
};
|
|
107
|
+
/** Additional custom tabs */
|
|
108
|
+
additionalTabs?: ApprovalTabConfig<any>[];
|
|
109
|
+
/** Initial active tab (can be any tab key) */
|
|
110
|
+
initialTab?: string;
|
|
111
|
+
/** Toast notification handler */
|
|
112
|
+
toast?: (config: ToastConfig) => void;
|
|
113
|
+
/** Auto-refresh interval in seconds */
|
|
114
|
+
refreshInterval?: number;
|
|
115
|
+
/** Query invalidation handler */
|
|
116
|
+
invalidateQueries?: (queryKeys: string[]) => void;
|
|
117
|
+
/** Current user ID (for checking decisions) */
|
|
118
|
+
currentUserId?: string;
|
|
119
|
+
/** Custom components */
|
|
120
|
+
components?: {
|
|
121
|
+
Card?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
|
|
122
|
+
CardContent?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
|
|
123
|
+
Button?: React.ComponentType<any>;
|
|
124
|
+
Badge?: React.ComponentType<any>;
|
|
125
|
+
Skeleton?: React.ComponentType<{ className?: string }>;
|
|
126
|
+
// Table components
|
|
127
|
+
Table?: React.ComponentType<any>;
|
|
128
|
+
TableHeader?: React.ComponentType<any>;
|
|
129
|
+
TableBody?: React.ComponentType<any>;
|
|
130
|
+
TableRow?: React.ComponentType<any>;
|
|
131
|
+
TableHead?: React.ComponentType<any>;
|
|
132
|
+
TableCell?: React.ComponentType<any>;
|
|
133
|
+
// Tabs components
|
|
134
|
+
Tabs?: React.ComponentType<any>;
|
|
135
|
+
TabsList?: React.ComponentType<any>;
|
|
136
|
+
TabsTrigger?: React.ComponentType<any>;
|
|
137
|
+
TabsContent?: React.ComponentType<any>;
|
|
138
|
+
// Dialog components
|
|
139
|
+
Dialog?: React.ComponentType<any>;
|
|
140
|
+
DialogContent?: React.ComponentType<any>;
|
|
141
|
+
DialogHeader?: React.ComponentType<any>;
|
|
142
|
+
DialogTitle?: React.ComponentType<any>;
|
|
143
|
+
DialogDescription?: React.ComponentType<any>;
|
|
144
|
+
DialogFooter?: React.ComponentType<any>;
|
|
145
|
+
};
|
|
146
|
+
/** Additional class name */
|
|
147
|
+
className?: string;
|
|
148
|
+
/** Optional help text shown below description */
|
|
149
|
+
helpText?: React.ReactNode;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// GENERIC APPROVAL TAB COMPONENT
|
|
154
|
+
// ============================================================================
|
|
155
|
+
|
|
156
|
+
interface ApprovalTabProps<T extends BaseApprovalItem> {
|
|
157
|
+
config: ApprovalTabConfig<T>;
|
|
158
|
+
refreshInterval: number;
|
|
159
|
+
toast?: (config: ToastConfig) => void;
|
|
160
|
+
invalidateQueries?: (queryKeys: string[]) => void;
|
|
161
|
+
currentUserId?: string;
|
|
162
|
+
components?: ApprovalsPageBaseProps['components'];
|
|
163
|
+
/** Trigger to force refresh from parent */
|
|
164
|
+
refreshTrigger?: number;
|
|
165
|
+
/** Callback to report loading state to parent */
|
|
166
|
+
onLoadingChange?: (isLoading: boolean) => void;
|
|
167
|
+
/** Callback to report seconds remaining to parent */
|
|
168
|
+
onSecondsRemainingChange?: (seconds: number) => void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function ApprovalTab<T extends BaseApprovalItem>({
|
|
172
|
+
config,
|
|
173
|
+
refreshInterval,
|
|
174
|
+
toast,
|
|
175
|
+
invalidateQueries,
|
|
176
|
+
currentUserId,
|
|
177
|
+
components = {},
|
|
178
|
+
refreshTrigger,
|
|
179
|
+
onLoadingChange,
|
|
180
|
+
onSecondsRemainingChange,
|
|
181
|
+
}: ApprovalTabProps<T>) {
|
|
182
|
+
const [data, setData] = useState<T[]>([]);
|
|
183
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
184
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
185
|
+
|
|
186
|
+
const fetchData = useCallback(async () => {
|
|
187
|
+
setIsLoading(true);
|
|
188
|
+
onLoadingChange?.(true);
|
|
189
|
+
try {
|
|
190
|
+
const result = await config.fetchData();
|
|
191
|
+
setData(result);
|
|
192
|
+
if (invalidateQueries && config.queryKeys) {
|
|
193
|
+
invalidateQueries(config.queryKeys);
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error(`Error fetching ${config.key} approvals:`, error);
|
|
197
|
+
toast?.({
|
|
198
|
+
title: `Failed to fetch ${config.label.toLowerCase()}`,
|
|
199
|
+
description: error instanceof Error ? error.message : "Unknown error",
|
|
200
|
+
variant: "destructive",
|
|
201
|
+
});
|
|
202
|
+
} finally {
|
|
203
|
+
setIsLoading(false);
|
|
204
|
+
onLoadingChange?.(false);
|
|
205
|
+
}
|
|
206
|
+
}, [config, invalidateQueries, toast, onLoadingChange]);
|
|
207
|
+
|
|
208
|
+
const { secondsRemaining, refreshNow } = useAutoRefresh({
|
|
209
|
+
intervalSeconds: refreshInterval,
|
|
210
|
+
refresh: fetchData,
|
|
211
|
+
isBlocked: isLoading || isProcessing,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Report seconds remaining to parent
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (secondsRemaining !== null) {
|
|
217
|
+
onSecondsRemainingChange?.(secondsRemaining);
|
|
218
|
+
}
|
|
219
|
+
}, [secondsRemaining, onSecondsRemainingChange]);
|
|
220
|
+
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
void refreshNow();
|
|
223
|
+
}, [refreshNow]);
|
|
224
|
+
|
|
225
|
+
// Trigger refresh when refreshTrigger changes from parent
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (refreshTrigger && refreshTrigger > 0) {
|
|
228
|
+
void refreshNow();
|
|
229
|
+
}
|
|
230
|
+
}, [refreshTrigger, refreshNow]);
|
|
231
|
+
|
|
232
|
+
const handleAction = async (
|
|
233
|
+
action: ((item: T) => Promise<void>) | undefined,
|
|
234
|
+
item: T,
|
|
235
|
+
successMessage: string
|
|
236
|
+
) => {
|
|
237
|
+
if (!action) return;
|
|
238
|
+
setIsProcessing(true);
|
|
239
|
+
try {
|
|
240
|
+
await action(item);
|
|
241
|
+
toast?.({ title: successMessage });
|
|
242
|
+
await delay(500);
|
|
243
|
+
await refreshNow();
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.error("Action error:", error);
|
|
246
|
+
toast?.({
|
|
247
|
+
title: "Action failed",
|
|
248
|
+
description: error instanceof Error ? error.message : "Unknown error",
|
|
249
|
+
variant: "destructive",
|
|
250
|
+
});
|
|
251
|
+
} finally {
|
|
252
|
+
setIsProcessing(false);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Add actions column to config columns
|
|
257
|
+
const columnsWithActions: ColumnDef<T>[] = [
|
|
258
|
+
...config.columns,
|
|
259
|
+
{
|
|
260
|
+
key: "actions",
|
|
261
|
+
header: "Actions",
|
|
262
|
+
align: "right",
|
|
263
|
+
cell: (item) => {
|
|
264
|
+
const canCommit = config.canCommit?.(item) ?? false;
|
|
265
|
+
const hasDecided = config.hasUserDecided?.(item) ?? false;
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<ActionButtonGroup>
|
|
269
|
+
{/* Review button */}
|
|
270
|
+
{config.actions.onReview && !hasDecided && (
|
|
271
|
+
<ActionButton
|
|
272
|
+
action={canCommit ? "commit" : "review"}
|
|
273
|
+
onClick={() => handleAction(
|
|
274
|
+
canCommit ? config.actions.onCommit : config.actions.onReview,
|
|
275
|
+
item,
|
|
276
|
+
canCommit ? "Committed successfully" : "Reviewed successfully"
|
|
277
|
+
)}
|
|
278
|
+
title={canCommit ? "Commit change" : "Review request"}
|
|
279
|
+
disabled={isProcessing}
|
|
280
|
+
ButtonComponent={components.Button}
|
|
281
|
+
/>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{/* Revoke button (shown if user has decided) */}
|
|
285
|
+
{config.actions.onRevoke && hasDecided && (
|
|
286
|
+
<ActionButton
|
|
287
|
+
action="revoke"
|
|
288
|
+
onClick={() => handleAction(config.actions.onRevoke, item, "Decision revoked")}
|
|
289
|
+
title="Revoke your decision"
|
|
290
|
+
disabled={isProcessing}
|
|
291
|
+
ButtonComponent={components.Button}
|
|
292
|
+
/>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{/* Commit button (if ready and separate from review) */}
|
|
296
|
+
{config.actions.onCommit && canCommit && !config.actions.onReview && (
|
|
297
|
+
<ActionButton
|
|
298
|
+
action="commit"
|
|
299
|
+
onClick={() => handleAction(config.actions.onCommit, item, "Committed successfully")}
|
|
300
|
+
title="Commit change"
|
|
301
|
+
disabled={isProcessing}
|
|
302
|
+
ButtonComponent={components.Button}
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{/* View button */}
|
|
307
|
+
{config.actions.onView && (
|
|
308
|
+
<ActionButton
|
|
309
|
+
action="view"
|
|
310
|
+
onClick={() => config.actions.onView!(item)}
|
|
311
|
+
title="View details"
|
|
312
|
+
ButtonComponent={components.Button}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{/* Cancel/Delete button */}
|
|
317
|
+
{config.actions.onCancel && (
|
|
318
|
+
<ActionButton
|
|
319
|
+
action="cancel"
|
|
320
|
+
onClick={() => handleAction(config.actions.onCancel, item, "Cancelled successfully")}
|
|
321
|
+
title="Cancel request"
|
|
322
|
+
disabled={isProcessing}
|
|
323
|
+
ButtonComponent={components.Button}
|
|
324
|
+
/>
|
|
325
|
+
)}
|
|
326
|
+
{config.actions.onDelete && (
|
|
327
|
+
<ActionButton
|
|
328
|
+
action="delete"
|
|
329
|
+
onClick={() => handleAction(config.actions.onDelete, item, "Deleted successfully")}
|
|
330
|
+
title="Delete request"
|
|
331
|
+
disabled={isProcessing}
|
|
332
|
+
ButtonComponent={components.Button}
|
|
333
|
+
/>
|
|
334
|
+
)}
|
|
335
|
+
</ActionButtonGroup>
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
// Loading state
|
|
342
|
+
if (isLoading && data.length === 0) {
|
|
343
|
+
return (
|
|
344
|
+
<div style={{ padding: "1rem" }}>
|
|
345
|
+
<LoadingSkeleton rows={3} type="table" SkeletonComponent={components.Skeleton} />
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Empty state
|
|
351
|
+
if (!data || data.length === 0) {
|
|
352
|
+
return (
|
|
353
|
+
<div style={{ padding: "1rem" }}>
|
|
354
|
+
<EmptyState
|
|
355
|
+
icon={config.emptyState.icon}
|
|
356
|
+
title={config.emptyState.title}
|
|
357
|
+
description={config.emptyState.description}
|
|
358
|
+
/>
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Table view
|
|
364
|
+
return (
|
|
365
|
+
<DataTable
|
|
366
|
+
data={data}
|
|
367
|
+
columns={columnsWithActions}
|
|
368
|
+
components={{
|
|
369
|
+
Table: components.Table,
|
|
370
|
+
TableHeader: components.TableHeader,
|
|
371
|
+
TableBody: components.TableBody,
|
|
372
|
+
TableRow: components.TableRow,
|
|
373
|
+
TableHead: components.TableHead,
|
|
374
|
+
TableCell: components.TableCell,
|
|
375
|
+
Skeleton: components.Skeleton,
|
|
376
|
+
}}
|
|
377
|
+
/>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ============================================================================
|
|
382
|
+
// MAIN APPROVALS PAGE COMPONENT
|
|
383
|
+
// ============================================================================
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* ApprovalsPage - Generic approvals page with tabs for access, roles, and policies
|
|
387
|
+
*
|
|
388
|
+
* @example
|
|
389
|
+
* ```tsx
|
|
390
|
+
* <ApprovalsPage
|
|
391
|
+
* tabs={{
|
|
392
|
+
* access: {
|
|
393
|
+
* key: 'access',
|
|
394
|
+
* label: 'Access',
|
|
395
|
+
* fetchData: api.admin.accessApprovals.list,
|
|
396
|
+
* columns: accessColumns,
|
|
397
|
+
* emptyState: { icon: <User />, title: 'No pending requests', description: '...' },
|
|
398
|
+
* actions: {
|
|
399
|
+
* onReview: handleReview,
|
|
400
|
+
* onCommit: handleCommit,
|
|
401
|
+
* onCancel: handleCancel,
|
|
402
|
+
* },
|
|
403
|
+
* canCommit: (item) => item.commitReady,
|
|
404
|
+
* },
|
|
405
|
+
* // ... other tabs
|
|
406
|
+
* }}
|
|
407
|
+
* toast={toast}
|
|
408
|
+
* invalidateQueries={queryClient.invalidateQueries}
|
|
409
|
+
* />
|
|
410
|
+
* ```
|
|
411
|
+
*/
|
|
412
|
+
export function ApprovalsPageBase<
|
|
413
|
+
TAccess extends AccessApprovalItem = AccessApprovalItem,
|
|
414
|
+
TRole extends RoleApprovalItem = RoleApprovalItem,
|
|
415
|
+
TPolicy extends PolicyApprovalItem = PolicyApprovalItem
|
|
416
|
+
>({
|
|
417
|
+
title = "Change Requests",
|
|
418
|
+
description = "Review and approve pending access, role, and policy change requests.",
|
|
419
|
+
tabs,
|
|
420
|
+
additionalTabs = [],
|
|
421
|
+
initialTab = "access",
|
|
422
|
+
toast,
|
|
423
|
+
refreshInterval = 15,
|
|
424
|
+
invalidateQueries,
|
|
425
|
+
currentUserId,
|
|
426
|
+
components = {},
|
|
427
|
+
className,
|
|
428
|
+
helpText,
|
|
429
|
+
}: ApprovalsPageBaseProps<TAccess, TRole, TPolicy>) {
|
|
430
|
+
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
|
431
|
+
const [badgeCounts, setBadgeCounts] = useState<Record<string, number>>({});
|
|
432
|
+
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
433
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
434
|
+
const [secondsRemaining, setSecondsRemaining] = useState(refreshInterval);
|
|
435
|
+
|
|
436
|
+
// Components (use defaultComponents from ui if not provided)
|
|
437
|
+
const Card = components.Card || defaultComponents.Card;
|
|
438
|
+
const CardContent = components.CardContent || defaultComponents.CardContent;
|
|
439
|
+
const Button = components.Button || defaultComponents.Button;
|
|
440
|
+
|
|
441
|
+
// Handle manual refresh
|
|
442
|
+
const handleRefresh = useCallback(() => {
|
|
443
|
+
setRefreshTrigger((prev) => prev + 1);
|
|
444
|
+
}, []);
|
|
445
|
+
|
|
446
|
+
// Build tab definitions
|
|
447
|
+
const tabDefs = [];
|
|
448
|
+
|
|
449
|
+
if (tabs.access) {
|
|
450
|
+
tabDefs.push({
|
|
451
|
+
key: "access",
|
|
452
|
+
label: tabs.access.label || "Access",
|
|
453
|
+
icon: tabs.access.icon || <User style={{ height: "1rem", width: "1rem" }} />,
|
|
454
|
+
badge: badgeCounts.access,
|
|
455
|
+
content: (
|
|
456
|
+
<ApprovalTab
|
|
457
|
+
config={tabs.access as ApprovalTabConfig<any>}
|
|
458
|
+
refreshInterval={refreshInterval}
|
|
459
|
+
toast={toast}
|
|
460
|
+
invalidateQueries={invalidateQueries}
|
|
461
|
+
currentUserId={currentUserId}
|
|
462
|
+
components={components}
|
|
463
|
+
refreshTrigger={refreshTrigger}
|
|
464
|
+
onLoadingChange={setIsRefreshing}
|
|
465
|
+
onSecondsRemainingChange={setSecondsRemaining}
|
|
466
|
+
/>
|
|
467
|
+
),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (tabs.roles) {
|
|
472
|
+
tabDefs.push({
|
|
473
|
+
key: "roles",
|
|
474
|
+
label: tabs.roles.label || "Roles",
|
|
475
|
+
icon: tabs.roles.icon || <Shield style={{ height: "1rem", width: "1rem" }} />,
|
|
476
|
+
badge: badgeCounts.roles,
|
|
477
|
+
content: (
|
|
478
|
+
<ApprovalTab
|
|
479
|
+
config={tabs.roles as ApprovalTabConfig<any>}
|
|
480
|
+
refreshInterval={refreshInterval}
|
|
481
|
+
toast={toast}
|
|
482
|
+
invalidateQueries={invalidateQueries}
|
|
483
|
+
currentUserId={currentUserId}
|
|
484
|
+
components={components}
|
|
485
|
+
refreshTrigger={refreshTrigger}
|
|
486
|
+
onLoadingChange={setIsRefreshing}
|
|
487
|
+
onSecondsRemainingChange={setSecondsRemaining}
|
|
488
|
+
/>
|
|
489
|
+
),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (tabs.policies) {
|
|
494
|
+
tabDefs.push({
|
|
495
|
+
key: "policies",
|
|
496
|
+
label: tabs.policies.label || "Policies",
|
|
497
|
+
icon: tabs.policies.icon || <FileKey style={{ height: "1rem", width: "1rem" }} />,
|
|
498
|
+
badge: badgeCounts.policies,
|
|
499
|
+
content: (
|
|
500
|
+
<ApprovalTab
|
|
501
|
+
config={tabs.policies as ApprovalTabConfig<any>}
|
|
502
|
+
refreshInterval={refreshInterval}
|
|
503
|
+
toast={toast}
|
|
504
|
+
invalidateQueries={invalidateQueries}
|
|
505
|
+
currentUserId={currentUserId}
|
|
506
|
+
components={components}
|
|
507
|
+
refreshTrigger={refreshTrigger}
|
|
508
|
+
onLoadingChange={setIsRefreshing}
|
|
509
|
+
onSecondsRemainingChange={setSecondsRemaining}
|
|
510
|
+
/>
|
|
511
|
+
),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Add any additional custom tabs
|
|
516
|
+
for (const tabConfig of additionalTabs) {
|
|
517
|
+
tabDefs.push({
|
|
518
|
+
key: tabConfig.key,
|
|
519
|
+
label: tabConfig.label,
|
|
520
|
+
icon: tabConfig.icon,
|
|
521
|
+
badge: badgeCounts[tabConfig.key],
|
|
522
|
+
content: (
|
|
523
|
+
<ApprovalTab
|
|
524
|
+
config={tabConfig}
|
|
525
|
+
refreshInterval={refreshInterval}
|
|
526
|
+
toast={toast}
|
|
527
|
+
invalidateQueries={invalidateQueries}
|
|
528
|
+
currentUserId={currentUserId}
|
|
529
|
+
components={components}
|
|
530
|
+
refreshTrigger={refreshTrigger}
|
|
531
|
+
onLoadingChange={setIsRefreshing}
|
|
532
|
+
onSecondsRemainingChange={setSecondsRemaining}
|
|
533
|
+
/>
|
|
534
|
+
),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
<div style={{ padding: "1.5rem" }} className={className}>
|
|
540
|
+
{/* Header */}
|
|
541
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.5rem", flexWrap: "wrap", gap: "1rem" }}>
|
|
542
|
+
<div>
|
|
543
|
+
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, display: "flex", alignItems: "center", gap: "0.5rem", margin: 0 }}>
|
|
544
|
+
<CheckSquare style={{ width: "1.5rem", height: "1.5rem" }} />
|
|
545
|
+
{title}
|
|
546
|
+
</h1>
|
|
547
|
+
<p style={{ fontSize: "0.875rem", color: "#6b7280", margin: "0.25rem 0 0" }}>{description}</p>
|
|
548
|
+
</div>
|
|
549
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
550
|
+
<RefreshButton
|
|
551
|
+
onClick={handleRefresh}
|
|
552
|
+
isRefreshing={isRefreshing}
|
|
553
|
+
secondsRemaining={secondsRemaining}
|
|
554
|
+
title="Refresh now"
|
|
555
|
+
/>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
{/* Help text */}
|
|
560
|
+
{helpText && <div style={{ marginBottom: "1rem" }}>{helpText}</div>}
|
|
561
|
+
|
|
562
|
+
{/* Tabs */}
|
|
563
|
+
<Card>
|
|
564
|
+
<CardContent style={{ padding: 0 }}>
|
|
565
|
+
<TabsView
|
|
566
|
+
tabs={tabDefs}
|
|
567
|
+
activeTab={activeTab}
|
|
568
|
+
onTabChange={setActiveTab}
|
|
569
|
+
components={{
|
|
570
|
+
Tabs: components.Tabs,
|
|
571
|
+
TabsList: components.TabsList,
|
|
572
|
+
TabsTrigger: components.TabsTrigger,
|
|
573
|
+
TabsContent: components.TabsContent,
|
|
574
|
+
Badge: components.Badge,
|
|
575
|
+
}}
|
|
576
|
+
/>
|
|
577
|
+
</CardContent>
|
|
578
|
+
</Card>
|
|
579
|
+
</div>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ============================================================================
|
|
584
|
+
// HELPER FUNCTIONS FOR CREATING COMMON COLUMNS
|
|
585
|
+
// ============================================================================
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Create user column for access approvals
|
|
589
|
+
*/
|
|
590
|
+
export function createUserColumn<T extends { username: string }>(): ColumnDef<T> {
|
|
591
|
+
return {
|
|
592
|
+
key: "user",
|
|
593
|
+
header: "User",
|
|
594
|
+
cell: (item) => (
|
|
595
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
596
|
+
<div style={{ display: "flex", height: "2rem", width: "2rem", alignItems: "center", justifyContent: "center", borderRadius: "9999px", backgroundColor: "rgba(59, 130, 246, 0.1)" }}>
|
|
597
|
+
<User style={{ height: "1rem", width: "1rem", color: "#3b82f6" }} />
|
|
598
|
+
</div>
|
|
599
|
+
<span style={{ fontWeight: 500 }}>{item.username}</span>
|
|
600
|
+
</div>
|
|
601
|
+
),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Create role column
|
|
607
|
+
*/
|
|
608
|
+
export function createRoleColumn<T extends { role?: string; roleId?: string }>(): ColumnDef<T> {
|
|
609
|
+
return {
|
|
610
|
+
key: "role",
|
|
611
|
+
header: "Role",
|
|
612
|
+
responsive: "sm",
|
|
613
|
+
cell: (item) => (
|
|
614
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
615
|
+
<Shield style={{ height: "1rem", width: "1rem", color: "#6b7280" }} />
|
|
616
|
+
<span style={{ fontSize: "0.875rem", fontFamily: "monospace" }}>{item.role || item.roleId}</span>
|
|
617
|
+
</div>
|
|
618
|
+
),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Create timestamp column
|
|
624
|
+
*/
|
|
625
|
+
export function createTimestampColumn<T extends { timestamp?: string | number; createdAt?: number }>(
|
|
626
|
+
options?: { header?: string; responsive?: "sm" | "md" | "lg" }
|
|
627
|
+
): ColumnDef<T> {
|
|
628
|
+
return {
|
|
629
|
+
key: "timestamp",
|
|
630
|
+
header: options?.header || "Created",
|
|
631
|
+
responsive: options?.responsive || "md",
|
|
632
|
+
cell: (item) => {
|
|
633
|
+
const ts = item.timestamp || (item.createdAt ? item.createdAt * 1000 : undefined);
|
|
634
|
+
return (
|
|
635
|
+
<span style={{ fontSize: "0.875rem", color: "#6b7280" }}>
|
|
636
|
+
{ts ? formatTimestamp(ts) : "-"}
|
|
637
|
+
</span>
|
|
638
|
+
);
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Create status column
|
|
645
|
+
*/
|
|
646
|
+
export function createStatusColumn<T extends { status?: string; commitReady?: boolean }>(
|
|
647
|
+
options?: { getStatus?: (item: T) => string }
|
|
648
|
+
): ColumnDef<T> {
|
|
649
|
+
return {
|
|
650
|
+
key: "status",
|
|
651
|
+
header: "Status",
|
|
652
|
+
cell: (item) => {
|
|
653
|
+
const status = options?.getStatus?.(item) || item.status || "pending";
|
|
654
|
+
return <StatusBadge status={status} />;
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Create progress column for threshold-based approvals
|
|
661
|
+
*/
|
|
662
|
+
export function createProgressColumn<T extends { approvalCount?: number; rejectionCount?: number; threshold: number }>(): ColumnDef<T> {
|
|
663
|
+
return {
|
|
664
|
+
key: "progress",
|
|
665
|
+
header: "Progress",
|
|
666
|
+
responsive: "md",
|
|
667
|
+
cell: (item) => (
|
|
668
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
669
|
+
<span style={{ color: "#16a34a" }}>{item.approvalCount || 0}</span>
|
|
670
|
+
<span style={{ color: "#6b7280" }}>/</span>
|
|
671
|
+
<span>{item.threshold}</span>
|
|
672
|
+
{(item.rejectionCount ?? 0) > 0 && (
|
|
673
|
+
<span style={{ color: "#dc2626", fontSize: "0.875rem" }}>
|
|
674
|
+
({item.rejectionCount} rejected)
|
|
675
|
+
</span>
|
|
676
|
+
)}
|
|
677
|
+
</div>
|
|
678
|
+
),
|
|
679
|
+
};
|
|
680
|
+
}
|