@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,797 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ApprovalsPage - Review and approve pending changes
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```tsx
|
|
6
|
+
* <ApprovalsPage
|
|
7
|
+
* adminAPI={AdminAPI}
|
|
8
|
+
* tideContext={tideContext}
|
|
9
|
+
* policyLogsAPI={policyLogsAPI}
|
|
10
|
+
* currentUsername={username}
|
|
11
|
+
* />
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { AdminAPI as DefaultAdminAPI } from "@tidecloak/js";
|
|
16
|
+
import {
|
|
17
|
+
ApprovalsPageBase,
|
|
18
|
+
type ApprovalsPageBaseProps,
|
|
19
|
+
type ApprovalTabConfig,
|
|
20
|
+
type BaseApprovalItem,
|
|
21
|
+
type AccessApprovalItem,
|
|
22
|
+
type PolicyApprovalItem,
|
|
23
|
+
createUserColumn,
|
|
24
|
+
createRoleColumn,
|
|
25
|
+
createTimestampColumn,
|
|
26
|
+
createStatusColumn,
|
|
27
|
+
createProgressColumn,
|
|
28
|
+
} from "../base";
|
|
29
|
+
import { User, Shield, FileKey } from "lucide-react";
|
|
30
|
+
import { type PolicyLogsAPI } from "./LogsPage";
|
|
31
|
+
|
|
32
|
+
type AdminAPIInstance = {
|
|
33
|
+
setRealm?: (realm: string) => void;
|
|
34
|
+
getPendingChangeSets: () => Promise<any>;
|
|
35
|
+
approveChangeSet: (changeSet: any) => Promise<any>;
|
|
36
|
+
commitChangeSet: (changeSet: any) => Promise<any>;
|
|
37
|
+
cancelChangeSet: (changeSet: any) => Promise<any>;
|
|
38
|
+
getRawChangeSetRequest?: (changeSet: any) => Promise<any[]>;
|
|
39
|
+
approveChangeSetWithSignature?: (changeSet: any, signedRequest: string) => Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface TideApprovalResult {
|
|
43
|
+
id: string;
|
|
44
|
+
approved?: { request: Uint8Array };
|
|
45
|
+
denied?: boolean;
|
|
46
|
+
pending?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Get from useTideCloak() hook */
|
|
50
|
+
export interface TideApprovalContext {
|
|
51
|
+
initializeTideRequest: <T extends { encode: () => Uint8Array }>(request: T) => Promise<T>;
|
|
52
|
+
approveTideRequests: (requests: { id: string; request: Uint8Array }[]) => Promise<TideApprovalResult[]>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PolicyApprovalData {
|
|
56
|
+
id: string;
|
|
57
|
+
roleId: string;
|
|
58
|
+
requestedBy: string;
|
|
59
|
+
requestedByEmail?: string;
|
|
60
|
+
threshold: number;
|
|
61
|
+
approvalCount: number;
|
|
62
|
+
rejectionCount?: number;
|
|
63
|
+
commitReady?: boolean;
|
|
64
|
+
approvedBy?: string[];
|
|
65
|
+
deniedBy?: string[];
|
|
66
|
+
status: string;
|
|
67
|
+
timestamp: string | number;
|
|
68
|
+
policyRequestData: string;
|
|
69
|
+
contractCode?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Implement this to store policy approvals (or use createLocalStoragePolicyApprovalsAPI for dev) */
|
|
73
|
+
export interface PolicyApprovalsAPI {
|
|
74
|
+
getPendingPolicies: () => Promise<PolicyApprovalData[]>;
|
|
75
|
+
approve?: (id: string, rejected?: boolean, username?: string) => Promise<void>;
|
|
76
|
+
revoke?: (id: string, username?: string) => Promise<void>;
|
|
77
|
+
commit?: (id: string) => Promise<void>;
|
|
78
|
+
cancel?: (id: string) => Promise<void>;
|
|
79
|
+
_isLocalStorage?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const POLICY_APPROVALS_STORAGE_KEY = "tidecloak_policy_approvals";
|
|
83
|
+
|
|
84
|
+
/** localStorage adapter for development */
|
|
85
|
+
export function createLocalStoragePolicyApprovalsAPI(
|
|
86
|
+
storageKey = POLICY_APPROVALS_STORAGE_KEY
|
|
87
|
+
): PolicyApprovalsAPI {
|
|
88
|
+
const getAll = (): PolicyApprovalData[] => {
|
|
89
|
+
try {
|
|
90
|
+
const data = localStorage.getItem(storageKey);
|
|
91
|
+
return data ? JSON.parse(data) : [];
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const saveAll = (approvals: PolicyApprovalData[]) => {
|
|
98
|
+
localStorage.setItem(storageKey, JSON.stringify(approvals));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
_isLocalStorage: true as const,
|
|
103
|
+
|
|
104
|
+
getPendingPolicies: async () => {
|
|
105
|
+
return getAll().filter((a) => a.status === "pending");
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
approve: async (id: string, rejected?: boolean, username?: string) => {
|
|
109
|
+
const approvals = getAll();
|
|
110
|
+
const index = approvals.findIndex((a) => a.id === id);
|
|
111
|
+
if (index >= 0) {
|
|
112
|
+
const approval = approvals[index];
|
|
113
|
+
approval.approvedBy = approval.approvedBy || [];
|
|
114
|
+
approval.deniedBy = approval.deniedBy || [];
|
|
115
|
+
|
|
116
|
+
if (rejected) {
|
|
117
|
+
if (username && !approval.deniedBy.includes(username)) {
|
|
118
|
+
approval.deniedBy.push(username);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
if (username && !approval.approvedBy.includes(username)) {
|
|
122
|
+
approval.approvedBy.push(username);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
approval.approvalCount = approval.approvedBy.length;
|
|
127
|
+
approval.rejectionCount = approval.deniedBy.length;
|
|
128
|
+
approval.commitReady = approval.approvalCount >= approval.threshold;
|
|
129
|
+
saveAll(approvals);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
revoke: async (id: string, username?: string) => {
|
|
134
|
+
const approvals = getAll();
|
|
135
|
+
const index = approvals.findIndex((a) => a.id === id);
|
|
136
|
+
if (index >= 0) {
|
|
137
|
+
const approval = approvals[index];
|
|
138
|
+
if (username) {
|
|
139
|
+
approval.approvedBy = (approval.approvedBy || []).filter((u) => u !== username);
|
|
140
|
+
approval.deniedBy = (approval.deniedBy || []).filter((u) => u !== username);
|
|
141
|
+
}
|
|
142
|
+
approval.approvalCount = (approval.approvedBy || []).length;
|
|
143
|
+
approval.rejectionCount = (approval.deniedBy || []).length;
|
|
144
|
+
approval.commitReady = approval.approvalCount >= approval.threshold;
|
|
145
|
+
saveAll(approvals);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
commit: async (id: string) => {
|
|
150
|
+
const approvals = getAll();
|
|
151
|
+
const index = approvals.findIndex((a) => a.id === id);
|
|
152
|
+
if (index >= 0) {
|
|
153
|
+
approvals[index].status = "committed";
|
|
154
|
+
saveAll(approvals);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
cancel: async (id: string) => {
|
|
159
|
+
const approvals = getAll().filter((a) => a.id !== id);
|
|
160
|
+
saveAll(approvals);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface AccessMetadataRecord {
|
|
166
|
+
changeSetId: string;
|
|
167
|
+
username: string;
|
|
168
|
+
userEmail?: string;
|
|
169
|
+
clientId?: string;
|
|
170
|
+
role?: string;
|
|
171
|
+
timestamp: string | number;
|
|
172
|
+
actionType?: string;
|
|
173
|
+
changeSetType?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Implement this to store access change metadata (or use createLocalStorageAccessMetadataAPI for dev) */
|
|
177
|
+
export interface AccessMetadataAPI {
|
|
178
|
+
getMetadata: (changeSetId: string) => Promise<AccessMetadataRecord | null>;
|
|
179
|
+
getAllMetadata: () => Promise<AccessMetadataRecord[]>;
|
|
180
|
+
saveMetadata: (record: AccessMetadataRecord) => Promise<void>;
|
|
181
|
+
deleteMetadata: (changeSetId: string) => Promise<void>;
|
|
182
|
+
_isLocalStorage?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ACCESS_METADATA_STORAGE_KEY = "tidecloak_access_metadata";
|
|
186
|
+
|
|
187
|
+
/** localStorage adapter for development */
|
|
188
|
+
export function createLocalStorageAccessMetadataAPI(
|
|
189
|
+
storageKey = ACCESS_METADATA_STORAGE_KEY
|
|
190
|
+
): AccessMetadataAPI {
|
|
191
|
+
const getAll = (): AccessMetadataRecord[] => {
|
|
192
|
+
try {
|
|
193
|
+
const data = localStorage.getItem(storageKey);
|
|
194
|
+
return data ? JSON.parse(data) : [];
|
|
195
|
+
} catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const saveAll = (records: AccessMetadataRecord[]) => {
|
|
201
|
+
localStorage.setItem(storageKey, JSON.stringify(records));
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
_isLocalStorage: true as const,
|
|
206
|
+
|
|
207
|
+
getMetadata: async (changeSetId: string) => {
|
|
208
|
+
const records = getAll();
|
|
209
|
+
return records.find((r) => r.changeSetId === changeSetId) || null;
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
getAllMetadata: async () => {
|
|
213
|
+
return getAll();
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
saveMetadata: async (record: AccessMetadataRecord) => {
|
|
217
|
+
const records = getAll();
|
|
218
|
+
const index = records.findIndex((r) => r.changeSetId === record.changeSetId);
|
|
219
|
+
if (index >= 0) {
|
|
220
|
+
records[index] = record;
|
|
221
|
+
} else {
|
|
222
|
+
records.push(record);
|
|
223
|
+
}
|
|
224
|
+
saveAll(records);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
deleteMetadata: async (changeSetId: string) => {
|
|
228
|
+
const records = getAll().filter((r) => r.changeSetId !== changeSetId);
|
|
229
|
+
saveAll(records);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Tab config with optional enclave support */
|
|
235
|
+
export interface EnclaveApprovalTabConfig {
|
|
236
|
+
/** Get raw request bytes for enclave approval (enables enclave if provided) */
|
|
237
|
+
getRawRequest?: (item: any) => Promise<Uint8Array | null>;
|
|
238
|
+
/** Submit signed approval after user approves in enclave */
|
|
239
|
+
submitSignedApproval?: (item: any, signedRequest: string) => Promise<void>;
|
|
240
|
+
/** Handle denial from enclave */
|
|
241
|
+
onEnclaveDenied?: (item: any) => Promise<void>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface ApprovalsPageProps extends Omit<ApprovalsPageBaseProps, "tabs"> {
|
|
245
|
+
/** AdminAPI instance */
|
|
246
|
+
adminAPI?: AdminAPIInstance;
|
|
247
|
+
/** Policy approvals storage adapter */
|
|
248
|
+
policyApprovalsAPI?: PolicyApprovalsAPI;
|
|
249
|
+
/** Policy logs adapter for activity tracking */
|
|
250
|
+
policyLogsAPI?: PolicyLogsAPI;
|
|
251
|
+
/** Access metadata adapter (merges with AdminAPI data) */
|
|
252
|
+
accessMetadataAPI?: AccessMetadataAPI;
|
|
253
|
+
/** Tide context for enclave approvals (from useTideCloak hook) */
|
|
254
|
+
tideContext?: TideApprovalContext;
|
|
255
|
+
/** Realm name */
|
|
256
|
+
realm?: string;
|
|
257
|
+
/** Custom tabs config */
|
|
258
|
+
tabs?: ApprovalsPageBaseProps["tabs"];
|
|
259
|
+
/** Additional tabs with optional enclave support */
|
|
260
|
+
additionalTabs?: (ApprovalTabConfig<BaseApprovalItem> & EnclaveApprovalTabConfig)[];
|
|
261
|
+
/** Show policy tab (default: true) */
|
|
262
|
+
showPolicyTab?: boolean;
|
|
263
|
+
/** Help text below description */
|
|
264
|
+
helpText?: ApprovalsPageBaseProps["helpText"];
|
|
265
|
+
/** Current username for logging */
|
|
266
|
+
currentUsername?: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Helper function to wrap an onReview handler with approval enclave logic.
|
|
271
|
+
* When tideContext is provided, the review will open the Tide approval enclave.
|
|
272
|
+
*/
|
|
273
|
+
function createEnclaveReviewHandler<T extends { id: string }>(
|
|
274
|
+
tideContext: TideApprovalContext | undefined,
|
|
275
|
+
getRawRequest: (item: T) => Promise<Uint8Array | null>,
|
|
276
|
+
submitSignedApproval: (item: T, signedRequest: string) => Promise<void>,
|
|
277
|
+
onDenied: (item: T) => Promise<void>,
|
|
278
|
+
fallbackApprove: (item: T) => Promise<void>
|
|
279
|
+
): (item: T) => Promise<void> {
|
|
280
|
+
return async (item: T) => {
|
|
281
|
+
// Use Tide approval enclave if context is provided
|
|
282
|
+
if (tideContext?.approveTideRequests) {
|
|
283
|
+
try {
|
|
284
|
+
const rawBytes = await getRawRequest(item);
|
|
285
|
+
|
|
286
|
+
if (rawBytes && rawBytes.length > 0) {
|
|
287
|
+
// Open Tide approval enclave (shows approval UI popup)
|
|
288
|
+
const results = await tideContext.approveTideRequests([{
|
|
289
|
+
id: `approval-${item.id}`,
|
|
290
|
+
request: rawBytes,
|
|
291
|
+
}]);
|
|
292
|
+
|
|
293
|
+
const result = results[0];
|
|
294
|
+
|
|
295
|
+
if (result?.approved) {
|
|
296
|
+
// Convert approved request back to base64 for the API
|
|
297
|
+
const signedRequest = btoa(String.fromCharCode(...result.approved.request));
|
|
298
|
+
await submitSignedApproval(item, signedRequest);
|
|
299
|
+
return;
|
|
300
|
+
} else if (result?.denied) {
|
|
301
|
+
// User denied in the enclave
|
|
302
|
+
await onDenied(item);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// If pending, do nothing - user closed the popup without deciding
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
// Tide approval enclave failed, fall back to simple approval
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Fallback to simple approval (no enclave)
|
|
314
|
+
await fallbackApprove(item);
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Warning banner for localStorage
|
|
319
|
+
function LocalStorageWarning() {
|
|
320
|
+
return (
|
|
321
|
+
<div
|
|
322
|
+
style={{
|
|
323
|
+
backgroundColor: "#fef3c7",
|
|
324
|
+
border: "1px solid #f59e0b",
|
|
325
|
+
borderRadius: "0.375rem",
|
|
326
|
+
padding: "0.75rem 1rem",
|
|
327
|
+
marginBottom: "1rem",
|
|
328
|
+
display: "flex",
|
|
329
|
+
alignItems: "flex-start",
|
|
330
|
+
gap: "0.5rem",
|
|
331
|
+
}}
|
|
332
|
+
>
|
|
333
|
+
<span style={{ fontSize: "1rem" }}>⚠️</span>
|
|
334
|
+
<div style={{ fontSize: "0.875rem", color: "#92400e" }}>
|
|
335
|
+
<strong>Development Mode:</strong> Policy approvals are stored in browser localStorage and will not persist
|
|
336
|
+
across devices or browsers. For production, implement your own{" "}
|
|
337
|
+
<code style={{ backgroundColor: "#fde68a", padding: "0 0.25rem", borderRadius: "0.25rem" }}>
|
|
338
|
+
PolicyApprovalsAPI
|
|
339
|
+
</code>{" "}
|
|
340
|
+
with a backend database.
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Helper to check if an error is a JSON parse error from a text success response.
|
|
348
|
+
* AdminAPI methods try to JSON.parse responses, but the server often returns plain text
|
|
349
|
+
* like "Successful", "Change set cancelled", etc. These are actually success messages.
|
|
350
|
+
*/
|
|
351
|
+
function isTextResponseError(err: unknown): boolean {
|
|
352
|
+
return err instanceof SyntaxError && (
|
|
353
|
+
err.message.includes("Unexpected token") ||
|
|
354
|
+
err.message.includes("is not valid JSON")
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Helper to extract change set info from an item.
|
|
360
|
+
* AdminAPI returns { changeSetId, changeSetType, actionType } directly on items.
|
|
361
|
+
* For backwards compatibility, also checks for retrievalInfo.
|
|
362
|
+
*/
|
|
363
|
+
function getChangeSet(item: any): { changeSetId: string; changeSetType: string; actionType: string } {
|
|
364
|
+
// If item has retrievalInfo (backwards compat), use it
|
|
365
|
+
if (item.retrievalInfo) {
|
|
366
|
+
return item.retrievalInfo;
|
|
367
|
+
}
|
|
368
|
+
// Otherwise use properties directly on item (AdminAPI format)
|
|
369
|
+
return {
|
|
370
|
+
changeSetId: item.changeSetId || item.draftRecordId || item.id,
|
|
371
|
+
changeSetType: item.changeSetType || "USER",
|
|
372
|
+
actionType: item.actionType || "ADD",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function ApprovalsPage({
|
|
377
|
+
adminAPI: adminAPIProp,
|
|
378
|
+
policyApprovalsAPI: policyApprovalsAPIProp,
|
|
379
|
+
policyLogsAPI,
|
|
380
|
+
accessMetadataAPI: accessMetadataAPIProp,
|
|
381
|
+
tideContext,
|
|
382
|
+
realm,
|
|
383
|
+
tabs: tabsProp,
|
|
384
|
+
additionalTabs,
|
|
385
|
+
showPolicyTab = true,
|
|
386
|
+
helpText: helpTextProp,
|
|
387
|
+
currentUsername,
|
|
388
|
+
...props
|
|
389
|
+
}: ApprovalsPageProps) {
|
|
390
|
+
// Use localStorage by default for policy approvals (like RolesPage pattern)
|
|
391
|
+
const policyApprovalsAPI = policyApprovalsAPIProp ?? createLocalStoragePolicyApprovalsAPI();
|
|
392
|
+
const isPolicyLocalStorage = policyApprovalsAPI._isLocalStorage === true;
|
|
393
|
+
|
|
394
|
+
// Use localStorage by default for access metadata
|
|
395
|
+
const accessMetadataAPI = accessMetadataAPIProp ?? createLocalStorageAccessMetadataAPI();
|
|
396
|
+
const isAccessMetadataLocalStorage = accessMetadataAPI._isLocalStorage === true;
|
|
397
|
+
|
|
398
|
+
// Use provided AdminAPI instance or fall back to default singleton
|
|
399
|
+
const api = (adminAPIProp || DefaultAdminAPI) as AdminAPIInstance;
|
|
400
|
+
|
|
401
|
+
// Set realm if provided
|
|
402
|
+
if (realm && api.setRealm) {
|
|
403
|
+
api.setRealm(realm);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Build tabs based on what APIs are available
|
|
407
|
+
const defaultTabs: ApprovalsPageBaseProps["tabs"] = {
|
|
408
|
+
access: {
|
|
409
|
+
key: "access",
|
|
410
|
+
label: "Access",
|
|
411
|
+
icon: <User style={{ height: "1rem", width: "1rem" }} />,
|
|
412
|
+
fetchData: async () => {
|
|
413
|
+
const approvals = await api.getPendingChangeSets();
|
|
414
|
+
|
|
415
|
+
// Get all metadata records for merging
|
|
416
|
+
const allMetadata = await accessMetadataAPI.getAllMetadata();
|
|
417
|
+
const metadataMap = new Map(allMetadata.map((m) => [m.changeSetId, m]));
|
|
418
|
+
|
|
419
|
+
// Map items and merge with metadata
|
|
420
|
+
return (approvals || []).map((item: any) => {
|
|
421
|
+
const changeSetId = item.changeSetId || item.draftRecordId || item.id;
|
|
422
|
+
const metadata = metadataMap.get(changeSetId);
|
|
423
|
+
|
|
424
|
+
// Extract from userRecord array if available (server returns user info there)
|
|
425
|
+
const firstUserRecord = item.userRecord?.[0];
|
|
426
|
+
|
|
427
|
+
// Determine commit readiness based on status field
|
|
428
|
+
// Server sets status to "APPROVED" after enclave approval
|
|
429
|
+
const isCommitReady =
|
|
430
|
+
item.status === "APPROVED" ||
|
|
431
|
+
item.deleteStatus === "APPROVED" ||
|
|
432
|
+
item.commitReady === true;
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
...item,
|
|
436
|
+
id: changeSetId,
|
|
437
|
+
// Extract username from userRecord[0] or item or metadata
|
|
438
|
+
username: firstUserRecord?.username || item.username || item.userName || metadata?.username || "",
|
|
439
|
+
// Extract clientId from userRecord[0] or item or metadata
|
|
440
|
+
clientId: firstUserRecord?.clientId || item.clientId || item.client || metadata?.clientId || "",
|
|
441
|
+
role: item.role || item.roleName || metadata?.role || "",
|
|
442
|
+
timestamp: item.timestamp || item.createdAt || item.created || metadata?.timestamp || Date.now(),
|
|
443
|
+
status: item.status || "pending",
|
|
444
|
+
// Add commitReady flag for canCommit check
|
|
445
|
+
commitReady: isCommitReady,
|
|
446
|
+
};
|
|
447
|
+
}) as AccessApprovalItem[];
|
|
448
|
+
},
|
|
449
|
+
columns: [
|
|
450
|
+
createUserColumn<AccessApprovalItem>(),
|
|
451
|
+
createRoleColumn<AccessApprovalItem>(),
|
|
452
|
+
{
|
|
453
|
+
key: "client",
|
|
454
|
+
header: "Client",
|
|
455
|
+
responsive: "lg" as const,
|
|
456
|
+
cell: (item) => (
|
|
457
|
+
<span style={{ fontSize: "0.875rem", color: "#6b7280" }}>{item.clientId || "-"}</span>
|
|
458
|
+
),
|
|
459
|
+
},
|
|
460
|
+
createTimestampColumn<AccessApprovalItem>({ responsive: "md" }),
|
|
461
|
+
createStatusColumn<AccessApprovalItem>(),
|
|
462
|
+
],
|
|
463
|
+
emptyState: {
|
|
464
|
+
icon: <User style={{ height: "3rem", width: "3rem", color: "#6b7280" }} />,
|
|
465
|
+
title: "No pending access requests",
|
|
466
|
+
description: "User access change requests will appear here.",
|
|
467
|
+
},
|
|
468
|
+
actions: {
|
|
469
|
+
onReview: createEnclaveReviewHandler<AccessApprovalItem>(
|
|
470
|
+
tideContext,
|
|
471
|
+
// Get raw request from AdminAPI
|
|
472
|
+
async (item) => {
|
|
473
|
+
if (!api.getRawChangeSetRequest) return null;
|
|
474
|
+
const changeSet = getChangeSet(item);
|
|
475
|
+
try {
|
|
476
|
+
const rawRequests = await api.getRawChangeSetRequest(changeSet);
|
|
477
|
+
if (rawRequests && rawRequests.length > 0) {
|
|
478
|
+
const rawRequest = rawRequests[0];
|
|
479
|
+
const changeSetDraftRequests = rawRequest.changeSetDraftRequests;
|
|
480
|
+
if (changeSetDraftRequests) {
|
|
481
|
+
return typeof changeSetDraftRequests === "string"
|
|
482
|
+
? Uint8Array.from(atob(changeSetDraftRequests), c => c.charCodeAt(0))
|
|
483
|
+
: changeSetDraftRequests;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
// Failed to get raw change set request
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
},
|
|
491
|
+
// Submit signed approval
|
|
492
|
+
async (item, signedRequest) => {
|
|
493
|
+
if (!api.approveChangeSetWithSignature) {
|
|
494
|
+
throw new Error("approveChangeSetWithSignature not available");
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
await api.approveChangeSetWithSignature(getChangeSet(item), signedRequest);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// Server returns plain text like "Successful" - treat as success
|
|
500
|
+
if (isTextResponseError(err)) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
// On denied - cancel the change set
|
|
507
|
+
async (item) => {
|
|
508
|
+
const changeSet = getChangeSet(item);
|
|
509
|
+
try {
|
|
510
|
+
await api.cancelChangeSet(changeSet);
|
|
511
|
+
} catch (err) {
|
|
512
|
+
// Server returns plain text - treat as success
|
|
513
|
+
if (!isTextResponseError(err)) {
|
|
514
|
+
throw err;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// Clean up metadata after denial
|
|
518
|
+
await accessMetadataAPI.deleteMetadata(changeSet.changeSetId);
|
|
519
|
+
},
|
|
520
|
+
// Fallback approve
|
|
521
|
+
async (item) => {
|
|
522
|
+
try {
|
|
523
|
+
await api.approveChangeSet(getChangeSet(item));
|
|
524
|
+
} catch (err) {
|
|
525
|
+
// Server returns plain text - treat as success
|
|
526
|
+
if (isTextResponseError(err)) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
throw err;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
),
|
|
533
|
+
onCommit: async (item) => {
|
|
534
|
+
const changeSet = getChangeSet(item);
|
|
535
|
+
try {
|
|
536
|
+
await api.commitChangeSet(changeSet);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
// Server returns plain text - treat as success
|
|
539
|
+
if (!isTextResponseError(err)) {
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Clean up metadata after successful commit
|
|
544
|
+
await accessMetadataAPI.deleteMetadata(changeSet.changeSetId);
|
|
545
|
+
},
|
|
546
|
+
onCancel: async (item) => {
|
|
547
|
+
const changeSet = getChangeSet(item);
|
|
548
|
+
try {
|
|
549
|
+
await api.cancelChangeSet(changeSet);
|
|
550
|
+
} catch (err) {
|
|
551
|
+
// Server returns plain text - treat as success
|
|
552
|
+
if (!isTextResponseError(err)) {
|
|
553
|
+
throw err;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// Clean up metadata after successful cancel
|
|
557
|
+
await accessMetadataAPI.deleteMetadata(changeSet.changeSetId);
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
canCommit: (item) => {
|
|
561
|
+
// commitReady is computed in fetchData based on status === "APPROVED"
|
|
562
|
+
return (item as any).commitReady === true;
|
|
563
|
+
},
|
|
564
|
+
queryKeys: ["access-approvals"],
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// Add policy tab (shown by default, can be hidden with showPolicyTab={false})
|
|
569
|
+
if (showPolicyTab) {
|
|
570
|
+
defaultTabs.policies = {
|
|
571
|
+
key: "policies",
|
|
572
|
+
label: "Policies",
|
|
573
|
+
icon: <FileKey style={{ height: "1rem", width: "1rem" }} />,
|
|
574
|
+
fetchData: async () => {
|
|
575
|
+
try {
|
|
576
|
+
const policies = await policyApprovalsAPI.getPendingPolicies();
|
|
577
|
+
return (policies || []) as PolicyApprovalItem[];
|
|
578
|
+
} catch {
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
columns: [
|
|
583
|
+
{
|
|
584
|
+
key: "role",
|
|
585
|
+
header: "Role",
|
|
586
|
+
cell: (item) => (
|
|
587
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
588
|
+
<Shield style={{ height: "1rem", width: "1rem", color: "#6b7280" }} />
|
|
589
|
+
<span style={{ fontFamily: "monospace" }}>{item.roleId}</span>
|
|
590
|
+
</div>
|
|
591
|
+
),
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
key: "requestedBy",
|
|
595
|
+
header: "Requested By",
|
|
596
|
+
responsive: "sm" as const,
|
|
597
|
+
cell: (item) => item.requestedByEmail || item.requestedBy,
|
|
598
|
+
},
|
|
599
|
+
createProgressColumn<PolicyApprovalItem>(),
|
|
600
|
+
createStatusColumn<PolicyApprovalItem>(),
|
|
601
|
+
],
|
|
602
|
+
emptyState: {
|
|
603
|
+
icon: <FileKey style={{ height: "3rem", width: "3rem", color: "#6b7280" }} />,
|
|
604
|
+
title: "No pending policy changes",
|
|
605
|
+
description: "Policy change requests will appear here.",
|
|
606
|
+
},
|
|
607
|
+
actions: {
|
|
608
|
+
onReview: policyApprovalsAPI.approve
|
|
609
|
+
? createEnclaveReviewHandler<PolicyApprovalItem>(
|
|
610
|
+
tideContext,
|
|
611
|
+
// Get raw request from policyRequestData
|
|
612
|
+
async (item) => {
|
|
613
|
+
if (item.policyRequestData) {
|
|
614
|
+
try {
|
|
615
|
+
return Uint8Array.from(atob(item.policyRequestData), c => c.charCodeAt(0));
|
|
616
|
+
} catch {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
},
|
|
622
|
+
// Submit signed approval: approve(id, rejected=false, username)
|
|
623
|
+
async (item, _signedRequest) => {
|
|
624
|
+
await policyApprovalsAPI.approve!(item.id, false, currentUsername);
|
|
625
|
+
// Log the approval
|
|
626
|
+
if (policyLogsAPI) {
|
|
627
|
+
await policyLogsAPI.addLog({
|
|
628
|
+
policyId: item.id,
|
|
629
|
+
roleId: item.roleId,
|
|
630
|
+
action: "approved",
|
|
631
|
+
performedBy: currentUsername || "unknown",
|
|
632
|
+
performedByEmail: item.requestedByEmail,
|
|
633
|
+
policyStatus: (item.approvalCount || 0) + 1 >= item.threshold ? "approved" : "pending",
|
|
634
|
+
policyThreshold: item.threshold,
|
|
635
|
+
approvalCount: (item.approvalCount || 0) + 1,
|
|
636
|
+
rejectionCount: item.rejectionCount,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
// On denied: approve(id, rejected=true, username)
|
|
641
|
+
async (item) => {
|
|
642
|
+
await policyApprovalsAPI.approve!(item.id, true, currentUsername);
|
|
643
|
+
// Log the denial
|
|
644
|
+
if (policyLogsAPI) {
|
|
645
|
+
await policyLogsAPI.addLog({
|
|
646
|
+
policyId: item.id,
|
|
647
|
+
roleId: item.roleId,
|
|
648
|
+
action: "denied",
|
|
649
|
+
performedBy: currentUsername || "unknown",
|
|
650
|
+
performedByEmail: item.requestedByEmail,
|
|
651
|
+
policyStatus: "pending",
|
|
652
|
+
policyThreshold: item.threshold,
|
|
653
|
+
approvalCount: item.approvalCount,
|
|
654
|
+
rejectionCount: (item.rejectionCount || 0) + 1,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
// Fallback approve (no enclave)
|
|
659
|
+
async (item) => {
|
|
660
|
+
await policyApprovalsAPI.approve!(item.id, false, currentUsername);
|
|
661
|
+
// Log the approval
|
|
662
|
+
if (policyLogsAPI) {
|
|
663
|
+
await policyLogsAPI.addLog({
|
|
664
|
+
policyId: item.id,
|
|
665
|
+
roleId: item.roleId,
|
|
666
|
+
action: "approved",
|
|
667
|
+
performedBy: currentUsername || "unknown",
|
|
668
|
+
performedByEmail: item.requestedByEmail,
|
|
669
|
+
policyStatus: (item.approvalCount || 0) + 1 >= item.threshold ? "approved" : "pending",
|
|
670
|
+
policyThreshold: item.threshold,
|
|
671
|
+
approvalCount: (item.approvalCount || 0) + 1,
|
|
672
|
+
rejectionCount: item.rejectionCount,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
)
|
|
677
|
+
: undefined,
|
|
678
|
+
onRevoke: policyApprovalsAPI.revoke && currentUsername
|
|
679
|
+
? async (item) => {
|
|
680
|
+
if (!confirm("Are you sure you want to revoke your decision on this policy?")) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
await policyApprovalsAPI.revoke!(item.id, currentUsername);
|
|
684
|
+
// Log the revoke
|
|
685
|
+
if (policyLogsAPI) {
|
|
686
|
+
await policyLogsAPI.addLog({
|
|
687
|
+
policyId: item.id,
|
|
688
|
+
roleId: item.roleId,
|
|
689
|
+
action: "revoked",
|
|
690
|
+
performedBy: currentUsername,
|
|
691
|
+
performedByEmail: item.requestedByEmail,
|
|
692
|
+
policyStatus: "pending",
|
|
693
|
+
policyThreshold: item.threshold,
|
|
694
|
+
approvalCount: item.approvalCount,
|
|
695
|
+
rejectionCount: item.rejectionCount,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
: undefined,
|
|
700
|
+
onCommit: policyApprovalsAPI.commit
|
|
701
|
+
? async (item) => {
|
|
702
|
+
await policyApprovalsAPI.commit!(item.id);
|
|
703
|
+
if (policyLogsAPI) {
|
|
704
|
+
await policyLogsAPI.addLog({
|
|
705
|
+
policyId: item.id,
|
|
706
|
+
roleId: item.roleId,
|
|
707
|
+
action: "committed",
|
|
708
|
+
performedBy: currentUsername || "unknown",
|
|
709
|
+
performedByEmail: item.requestedByEmail,
|
|
710
|
+
policyStatus: "committed",
|
|
711
|
+
policyThreshold: item.threshold,
|
|
712
|
+
approvalCount: item.approvalCount,
|
|
713
|
+
rejectionCount: item.rejectionCount,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
: undefined,
|
|
718
|
+
onCancel: policyApprovalsAPI.cancel
|
|
719
|
+
? async (item) => {
|
|
720
|
+
await policyApprovalsAPI.cancel!(item.id);
|
|
721
|
+
if (policyLogsAPI) {
|
|
722
|
+
await policyLogsAPI.addLog({
|
|
723
|
+
policyId: item.id,
|
|
724
|
+
roleId: item.roleId,
|
|
725
|
+
action: "cancelled",
|
|
726
|
+
performedBy: currentUsername || "unknown",
|
|
727
|
+
performedByEmail: item.requestedByEmail,
|
|
728
|
+
policyStatus: "cancelled",
|
|
729
|
+
policyThreshold: item.threshold,
|
|
730
|
+
approvalCount: item.approvalCount,
|
|
731
|
+
rejectionCount: item.rejectionCount,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
: undefined,
|
|
736
|
+
},
|
|
737
|
+
canCommit: (item) => item.commitReady || (item.approvalCount || 0) >= item.threshold,
|
|
738
|
+
hasUserDecided: currentUsername
|
|
739
|
+
? (item) => {
|
|
740
|
+
const approvedBy = item.approvedBy || [];
|
|
741
|
+
const deniedBy = item.deniedBy || [];
|
|
742
|
+
return approvedBy.includes(currentUsername) || deniedBy.includes(currentUsername);
|
|
743
|
+
}
|
|
744
|
+
: undefined,
|
|
745
|
+
queryKeys: ["policy-approvals"],
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Process additional tabs to wrap onReview with enclave support
|
|
750
|
+
const processedAdditionalTabs = additionalTabs?.map((tab) => {
|
|
751
|
+
// If tab has getRawRequest, wrap onReview with enclave support
|
|
752
|
+
if (tab.getRawRequest && tab.actions?.onReview) {
|
|
753
|
+
const originalOnReview = tab.actions.onReview;
|
|
754
|
+
return {
|
|
755
|
+
...tab,
|
|
756
|
+
actions: {
|
|
757
|
+
...tab.actions,
|
|
758
|
+
onReview: createEnclaveReviewHandler(
|
|
759
|
+
tideContext,
|
|
760
|
+
tab.getRawRequest,
|
|
761
|
+
tab.submitSignedApproval || (async (_item, _signedRequest) => {
|
|
762
|
+
// Default: just call original onReview
|
|
763
|
+
await originalOnReview(_item);
|
|
764
|
+
}),
|
|
765
|
+
tab.onEnclaveDenied || (async (item) => {
|
|
766
|
+
// Default: call onCancel or onDelete if available
|
|
767
|
+
if (tab.actions?.onCancel) await tab.actions.onCancel(item);
|
|
768
|
+
else if (tab.actions?.onDelete) await tab.actions.onDelete(item);
|
|
769
|
+
}),
|
|
770
|
+
originalOnReview
|
|
771
|
+
),
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
return tab;
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// Show localStorage warning if using localStorage API
|
|
779
|
+
const helpText = isPolicyLocalStorage ? (
|
|
780
|
+
<>
|
|
781
|
+
<LocalStorageWarning />
|
|
782
|
+
{helpTextProp}
|
|
783
|
+
</>
|
|
784
|
+
) : (
|
|
785
|
+
helpTextProp
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
return (
|
|
789
|
+
<ApprovalsPageBase
|
|
790
|
+
tabs={tabsProp || defaultTabs}
|
|
791
|
+
additionalTabs={processedAdditionalTabs}
|
|
792
|
+
helpText={helpText}
|
|
793
|
+
currentUserId={currentUsername}
|
|
794
|
+
{...props}
|
|
795
|
+
/>
|
|
796
|
+
);
|
|
797
|
+
}
|