@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,525 @@
1
+ /**
2
+ * RolesPage - Create and manage roles with policy support
3
+ *
4
+ * @example
5
+ * ```tsx
6
+ * <RolesPage
7
+ * adminAPI={AdminAPI}
8
+ * policyAPI={policyAPI}
9
+ * tideContext={tideContext}
10
+ * currentUsername={username}
11
+ * />
12
+ * ```
13
+ */
14
+
15
+ import { useEffect, useState } from "react";
16
+ import { AdminAPI as DefaultAdminAPI } from "@tidecloak/js";
17
+ import { RolesPageBase, type RolesPageBaseProps, type RoleItem, type RoleTypeConfig } from "../base";
18
+ import { type TemplateAPI } from "./TemplatesPage";
19
+ import { type PolicyApprovalData } from "./ApprovalsPage";
20
+ import { type PolicyLogsAPI } from "./LogsPage";
21
+ import {
22
+ loadTideLibs,
23
+ areTideLibsAvailable,
24
+ createTidePolicyHandler,
25
+ type TideContextMethods,
26
+ } from "../../../tide";
27
+
28
+ // Type for the AdminAPI instance
29
+ type AdminAPIInstance = {
30
+ setRealm?: (realm: string) => void;
31
+ // Realm roles
32
+ getRoles: () => Promise<any>;
33
+ createRole: (role: any) => Promise<any>;
34
+ updateRole: (roleName: string, role: any) => Promise<any>;
35
+ deleteRole: (roleName: string) => Promise<any>;
36
+ // Client roles
37
+ getClientRoles?: (clientId?: string) => Promise<any>;
38
+ createClientRole?: (role: any, clientId?: string) => Promise<any>;
39
+ updateClientRole?: (roleName: string, role: any, clientId?: string) => Promise<any>;
40
+ deleteClientRole?: (roleName: string, clientId?: string) => Promise<any>;
41
+ // Other
42
+ getTemplates?: () => Promise<any>;
43
+ getUsers?: () => Promise<any>;
44
+ getPendingChangeSets?: () => Promise<any>;
45
+ };
46
+
47
+ export interface RolePolicy {
48
+ roleName: string;
49
+ enabled: boolean;
50
+ contractType: string;
51
+ approvalType: "implicit" | "explicit";
52
+ executionType: "public" | "private";
53
+ threshold: number;
54
+ templateId?: string;
55
+ templateParams?: Record<string, any>;
56
+ createdAt?: string;
57
+ updatedAt?: string;
58
+ }
59
+
60
+ /** Implement this to store role policies (or use createLocalStoragePolicyAPI for dev) */
61
+ export interface PolicyAPI {
62
+ getPolicy: (roleName: string) => Promise<RolePolicy | null>;
63
+ upsertPolicy: (policy: RolePolicy) => Promise<RolePolicy | void>;
64
+ deletePolicy?: (roleName: string) => Promise<void>;
65
+ _isLocalStorage?: boolean;
66
+ }
67
+
68
+ const POLICY_STORAGE_KEY = "tidecloak_role_policies";
69
+
70
+ /** localStorage adapter for development */
71
+ export function createLocalStoragePolicyAPI(storageKey = POLICY_STORAGE_KEY): PolicyAPI {
72
+ const getAll = (): Record<string, RolePolicy> => {
73
+ try {
74
+ const data = localStorage.getItem(storageKey);
75
+ return data ? JSON.parse(data) : {};
76
+ } catch {
77
+ return {};
78
+ }
79
+ };
80
+
81
+ const saveAll = (policies: Record<string, RolePolicy>) => {
82
+ localStorage.setItem(storageKey, JSON.stringify(policies));
83
+ };
84
+
85
+ return {
86
+ _isLocalStorage: true as const,
87
+
88
+ getPolicy: async (roleName: string) => {
89
+ const policies = getAll();
90
+ return policies[roleName] || null;
91
+ },
92
+
93
+ upsertPolicy: async (policy: RolePolicy) => {
94
+ const policies = getAll();
95
+ const now = new Date().toISOString();
96
+ const existing = policies[policy.roleName];
97
+
98
+ policies[policy.roleName] = {
99
+ ...policy,
100
+ createdAt: existing?.createdAt || now,
101
+ updatedAt: now,
102
+ };
103
+
104
+ saveAll(policies);
105
+ return policies[policy.roleName];
106
+ },
107
+
108
+ deletePolicy: async (roleName: string) => {
109
+ const policies = getAll();
110
+ delete policies[roleName];
111
+ saveAll(policies);
112
+ },
113
+ };
114
+ }
115
+
116
+ export interface RolesPageProps<T extends RoleItem = RoleItem>
117
+ extends Omit<
118
+ RolesPageBaseProps<T>,
119
+ | "fetchRoles"
120
+ | "fetchTemplates"
121
+ | "fetchUsers"
122
+ | "fetchPendingApprovals"
123
+ | "fetchRolePolicy"
124
+ | "onCreate"
125
+ | "onUpdate"
126
+ | "onDelete"
127
+ | "onCreatePolicy"
128
+ | "toast"
129
+ | "invalidateQueries"
130
+ | "components"
131
+ | "roleType"
132
+ > {
133
+ /** AdminAPI instance */
134
+ adminAPI?: AdminAPIInstance;
135
+ /** Template adapter for policy templates */
136
+ templateAPI?: TemplateAPI;
137
+ /** Policy storage adapter */
138
+ policyAPI?: PolicyAPI;
139
+ /** Policy logs adapter for activity tracking */
140
+ policyLogsAPI?: PolicyLogsAPI;
141
+ /** Tide context for enclave workflow (from useTideCloak hook) */
142
+ tideContext?: TideContextMethods;
143
+ /** Callback when Tide policy request is created */
144
+ onTideRequestCreated?: (request: any, roleName: string) => Promise<void>;
145
+ /** Realm name */
146
+ realm?: string;
147
+ /** Role type: 'realm', 'client', or 'all' (default: 'realm') */
148
+ roleType?: RoleTypeConfig;
149
+ /** Override fetch roles */
150
+ fetchRoles?: RolesPageBaseProps<T>["fetchRoles"];
151
+ /** Override create */
152
+ onCreate?: RolesPageBaseProps<T>["onCreate"];
153
+ /** Override update */
154
+ onUpdate?: RolesPageBaseProps<T>["onUpdate"];
155
+ /** Override delete */
156
+ onDelete?: RolesPageBaseProps<T>["onDelete"];
157
+ /** Override policy creation */
158
+ onCreatePolicy?: RolesPageBaseProps<T>["onCreatePolicy"];
159
+ /** Toast handler */
160
+ toast?: RolesPageBaseProps<T>["toast"];
161
+ /** Query invalidation */
162
+ invalidateQueries?: RolesPageBaseProps<T>["invalidateQueries"];
163
+ /** Custom components */
164
+ components?: RolesPageBaseProps<T>["components"];
165
+ /** Help text below description */
166
+ helpText?: RolesPageBaseProps<T>["helpText"];
167
+ /** Current username for logging */
168
+ currentUsername?: string;
169
+ }
170
+
171
+ // Warning banner for localStorage
172
+ function LocalStorageWarning({ type }: { type: "policy" | "template" | "both" }) {
173
+ const typeText = type === "both" ? "Policies and templates" : type === "policy" ? "Policies" : "Templates";
174
+ const apiText = type === "both" ? "PolicyAPI and TemplateAPI" : type === "policy" ? "PolicyAPI" : "TemplateAPI";
175
+
176
+ return (
177
+ <div
178
+ style={{
179
+ backgroundColor: "#fef3c7",
180
+ border: "1px solid #f59e0b",
181
+ borderRadius: "0.375rem",
182
+ padding: "0.75rem 1rem",
183
+ marginBottom: "1rem",
184
+ display: "flex",
185
+ alignItems: "flex-start",
186
+ gap: "0.5rem",
187
+ }}
188
+ >
189
+ <span style={{ fontSize: "1rem" }}>⚠️</span>
190
+ <div style={{ fontSize: "0.875rem", color: "#92400e" }}>
191
+ <strong>Development Mode:</strong> {typeText} are stored in browser localStorage and will not persist across
192
+ devices or browsers. For production, implement your own{" "}
193
+ <code style={{ backgroundColor: "#fde68a", padding: "0 0.25rem", borderRadius: "0.25rem" }}>{apiText}</code>{" "}
194
+ with a backend database.
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ export function RolesPage<T extends RoleItem = RoleItem>({
201
+ adminAPI: adminAPIProp,
202
+ templateAPI,
203
+ policyAPI,
204
+ policyLogsAPI,
205
+ tideContext,
206
+ onTideRequestCreated,
207
+ realm,
208
+ roleType = "realm",
209
+ fetchRoles: fetchRolesProp,
210
+ onCreate: onCreateProp,
211
+ onUpdate: onUpdateProp,
212
+ onDelete: onDeleteProp,
213
+ onCreatePolicy: onCreatePolicyProp,
214
+ helpText: helpTextProp,
215
+ currentUsername,
216
+ ...props
217
+ }: RolesPageProps<T>) {
218
+ // Track if Tide libraries are available
219
+ const [tideLibsReady, setTideLibsReady] = useState(false);
220
+
221
+ // Load Tide libraries on mount
222
+ useEffect(() => {
223
+ loadTideLibs().then((available) => {
224
+ setTideLibsReady(available);
225
+ });
226
+ }, []);
227
+
228
+ // Check if using localStorage APIs
229
+ const isPolicyLocalStorage = policyAPI?._isLocalStorage === true;
230
+ const isTemplateLocalStorage = templateAPI?._isLocalStorage === true;
231
+ // Use provided AdminAPI instance or fall back to default singleton
232
+ const api = (adminAPIProp || DefaultAdminAPI) as AdminAPIInstance;
233
+
234
+ // Set realm if provided
235
+ if (realm && api.setRealm) {
236
+ api.setRealm(realm);
237
+ }
238
+
239
+ const fetchRoles =
240
+ fetchRolesProp ||
241
+ (async () => {
242
+ let roles: T[] = [];
243
+
244
+ if (roleType === "realm" || roleType === "all") {
245
+ const realmRoles = (await api.getRoles()) as T[];
246
+ // Mark as realm roles
247
+ roles = realmRoles.map((r) => ({ ...r, roleType: "realm" as const, clientRole: false }));
248
+ }
249
+
250
+ if ((roleType === "client" || roleType === "all") && api.getClientRoles) {
251
+ const clientRoles = (await api.getClientRoles()) as T[];
252
+ // Mark as client roles
253
+ const markedClientRoles = clientRoles.map((r) => ({ ...r, roleType: "client" as const, clientRole: true }));
254
+ roles = [...roles, ...markedClientRoles];
255
+ }
256
+
257
+ return { roles };
258
+ });
259
+
260
+ const fetchTemplates = async () => {
261
+ // Use templateAPI if provided, otherwise fall back to AdminAPI.getTemplates
262
+ if (templateAPI) {
263
+ const templates = await templateAPI.getTemplates();
264
+ return { templates: templates || [] };
265
+ }
266
+ const templates = api.getTemplates ? await api.getTemplates() : [];
267
+ return { templates: templates || [] };
268
+ };
269
+
270
+ const fetchUsers = async () => {
271
+ const users = api.getUsers ? await api.getUsers() : [];
272
+ return users || [];
273
+ };
274
+
275
+ const fetchPendingApprovals = async () => {
276
+ const approvals = api.getPendingChangeSets ? await api.getPendingChangeSets() : [];
277
+ return approvals || [];
278
+ };
279
+
280
+ // Fetch policy using PolicyAPI if provided
281
+ const fetchRolePolicy = policyAPI
282
+ ? async (roleName: string) => {
283
+ const policy = await policyAPI.getPolicy(roleName);
284
+ return { policy };
285
+ }
286
+ : async () => ({ policy: null });
287
+
288
+ const onCreate =
289
+ onCreateProp ||
290
+ (async (data: { name: string; description?: string; roleType?: "realm" | "client" }) => {
291
+ const targetType = data.roleType || (roleType === "client" ? "client" : "realm");
292
+
293
+ if (targetType === "client" && api.createClientRole) {
294
+ await api.createClientRole({ name: data.name, description: data.description });
295
+ } else {
296
+ await api.createRole({ name: data.name, description: data.description });
297
+ }
298
+ });
299
+
300
+ const onUpdate =
301
+ onUpdateProp ||
302
+ (async (data: { name: string; description?: string; clientRole?: boolean }) => {
303
+ // Check if it's a client role
304
+ if (data.clientRole && api.updateClientRole) {
305
+ await api.updateClientRole(data.name, { name: data.name, description: data.description });
306
+ } else {
307
+ await api.updateRole(data.name, { name: data.name, description: data.description });
308
+ }
309
+ });
310
+
311
+ const onDelete =
312
+ onDeleteProp ||
313
+ (async (roleName: string) => {
314
+ // For delete, we need to determine if it's a client role
315
+ // If roleType is 'client', use client API; if 'realm', use realm API
316
+ // If 'all', we try client first if available, then realm
317
+ if (roleType === "client" && api.deleteClientRole) {
318
+ await api.deleteClientRole(roleName);
319
+ } else if (roleType === "realm") {
320
+ await api.deleteRole(roleName);
321
+ } else if (roleType === "all") {
322
+ // Try to delete from all - one will succeed
323
+ // This is a limitation - ideally we'd know which type
324
+ try {
325
+ await api.deleteRole(roleName);
326
+ } catch {
327
+ if (api.deleteClientRole) {
328
+ await api.deleteClientRole(roleName);
329
+ }
330
+ }
331
+ } else {
332
+ await api.deleteRole(roleName);
333
+ }
334
+
335
+ // Also delete the policy if policyAPI is provided
336
+ if (policyAPI?.deletePolicy) {
337
+ try {
338
+ await policyAPI.deletePolicy(roleName);
339
+ } catch {
340
+ // Ignore policy deletion errors - role is already deleted
341
+ }
342
+ }
343
+
344
+ return { approvalCreated: false };
345
+ });
346
+
347
+ // Determine which policy creation handler to use
348
+ // Priority: 1. User-provided onCreatePolicy, 2. Tide workflow (if available), 3. PolicyAPI localStorage
349
+ const useTideWorkflow = tideContext && tideLibsReady && areTideLibsAvailable() && !onCreatePolicyProp;
350
+
351
+ // Helper to add a pending approval to PolicyApprovalsAPI
352
+ const addPendingApproval = async (
353
+ params: {
354
+ roleName: string;
355
+ policyConfig: any;
356
+ templateId?: string;
357
+ templateParams?: Record<string, any>;
358
+ threshold: number;
359
+ },
360
+ policyRequestData: string,
361
+ contractCode?: string,
362
+ requestedByEmail?: string
363
+ ) => {
364
+ const policyId = `policy-${params.roleName}-${Date.now()}`;
365
+ const approval: PolicyApprovalData = {
366
+ id: policyId,
367
+ roleId: params.roleName,
368
+ requestedBy: currentUsername || "unknown",
369
+ requestedByEmail,
370
+ threshold: params.threshold,
371
+ approvalCount: 0,
372
+ rejectionCount: 0,
373
+ commitReady: false,
374
+ approvedBy: [],
375
+ deniedBy: [],
376
+ status: "pending",
377
+ timestamp: Date.now(),
378
+ policyRequestData,
379
+ contractCode,
380
+ };
381
+
382
+ // Add to localStorage approvals (or custom API)
383
+ const storageKey = "tidecloak_policy_approvals";
384
+ try {
385
+ const existing = localStorage.getItem(storageKey);
386
+ const approvals: PolicyApprovalData[] = existing ? JSON.parse(existing) : [];
387
+ approvals.push(approval);
388
+ localStorage.setItem(storageKey, JSON.stringify(approvals));
389
+ } catch {
390
+ // Ignore localStorage errors
391
+ }
392
+
393
+ // Log the policy creation
394
+ if (policyLogsAPI) {
395
+ await policyLogsAPI.addLog({
396
+ policyId,
397
+ roleId: params.roleName,
398
+ action: "created",
399
+ performedBy: currentUsername || "unknown",
400
+ performedByEmail: requestedByEmail,
401
+ policyStatus: "pending",
402
+ policyThreshold: params.threshold,
403
+ approvalCount: 0,
404
+ rejectionCount: 0,
405
+ });
406
+ }
407
+ };
408
+
409
+ // Policy creation handler
410
+ const onCreatePolicy = (() => {
411
+ // 1. User provided custom handler - use it
412
+ if (onCreatePolicyProp) {
413
+ return onCreatePolicyProp;
414
+ }
415
+
416
+ // 2. Tide workflow available - use it as default
417
+ if (useTideWorkflow && tideContext) {
418
+ const tideHandler = createTidePolicyHandler(tideContext, onTideRequestCreated);
419
+
420
+ return async (params: {
421
+ roleName: string;
422
+ policyConfig: any;
423
+ templateId?: string;
424
+ templateParams?: Record<string, any>;
425
+ threshold: number;
426
+ }) => {
427
+ // Create Tide request (this creates and signs the PolicySignRequest)
428
+ // Returns the signed PolicySignRequest object
429
+ const signedRequest = await tideHandler(params);
430
+
431
+ // Encode the PolicySignRequest to bytes and convert to base64
432
+ // This is what the approval enclave needs to process the request
433
+ const requestBytes = signedRequest.encode();
434
+ const policyRequestData = btoa(String.fromCharCode(...requestBytes));
435
+
436
+ await addPendingApproval(params, policyRequestData);
437
+
438
+ // Also save to PolicyAPI for local tracking if provided
439
+ if (policyAPI) {
440
+ await policyAPI.upsertPolicy({
441
+ roleName: params.roleName,
442
+ enabled: params.policyConfig?.enabled ?? true,
443
+ contractType: params.policyConfig?.contractType || "tide",
444
+ approvalType: params.policyConfig?.approvalType || "explicit",
445
+ executionType: params.policyConfig?.executionType || "private",
446
+ threshold: params.threshold,
447
+ templateId: params.templateId,
448
+ templateParams: params.templateParams,
449
+ });
450
+ }
451
+ };
452
+ }
453
+
454
+ // 3. PolicyAPI only (localStorage or custom backend without Tide)
455
+ // Still add pending approval so it shows up in approvals page
456
+ return async (params: {
457
+ roleName: string;
458
+ policyConfig: any;
459
+ templateId?: string;
460
+ templateParams?: Record<string, any>;
461
+ threshold: number;
462
+ }) => {
463
+ // Add pending approval
464
+ const policyRequestData = JSON.stringify({
465
+ roleName: params.roleName,
466
+ templateId: params.templateId,
467
+ templateParams: params.templateParams,
468
+ threshold: params.threshold,
469
+ approvalType: params.policyConfig?.approvalType || "explicit",
470
+ executionType: params.policyConfig?.executionType || "private",
471
+ });
472
+ await addPendingApproval(params, policyRequestData);
473
+
474
+ // Save to PolicyAPI if provided
475
+ if (policyAPI) {
476
+ await policyAPI.upsertPolicy({
477
+ roleName: params.roleName,
478
+ enabled: params.policyConfig?.enabled ?? true,
479
+ contractType: params.policyConfig?.contractType || "default",
480
+ approvalType: params.policyConfig?.approvalType || "explicit",
481
+ executionType: params.policyConfig?.executionType || "private",
482
+ threshold: params.threshold,
483
+ templateId: params.templateId,
484
+ templateParams: params.templateParams,
485
+ });
486
+ }
487
+ };
488
+ })();
489
+
490
+ // Determine localStorage warning type
491
+ const localStorageWarningType = isPolicyLocalStorage && isTemplateLocalStorage
492
+ ? "both"
493
+ : isPolicyLocalStorage
494
+ ? "policy"
495
+ : isTemplateLocalStorage
496
+ ? "template"
497
+ : null;
498
+
499
+ // Show localStorage warning if using localStorage APIs
500
+ const helpText = localStorageWarningType ? (
501
+ <>
502
+ <LocalStorageWarning type={localStorageWarningType} />
503
+ {helpTextProp}
504
+ </>
505
+ ) : (
506
+ helpTextProp
507
+ );
508
+
509
+ return (
510
+ <RolesPageBase<T>
511
+ fetchRoles={fetchRoles}
512
+ fetchTemplates={fetchTemplates}
513
+ fetchUsers={fetchUsers}
514
+ fetchPendingApprovals={fetchPendingApprovals}
515
+ fetchRolePolicy={fetchRolePolicy}
516
+ onCreate={onCreate}
517
+ onUpdate={onUpdate}
518
+ onDelete={onDelete}
519
+ onCreatePolicy={onCreatePolicy}
520
+ roleType={roleType}
521
+ helpText={helpText}
522
+ {...props}
523
+ />
524
+ );
525
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * TemplatesPage - Manage policy templates
3
+ *
4
+ * @example
5
+ * ```tsx
6
+ * <TemplatesPage api={createLocalStorageTemplateAPI()} />
7
+ * ```
8
+ */
9
+
10
+ import { TemplatesPageBase, type TemplatesPageBaseProps, type PolicyTemplateItem, type TemplateFormData } from "../base";
11
+
12
+ /** Implement this to store templates (or use createLocalStorageTemplateAPI for dev) */
13
+ export interface TemplateAPI {
14
+ getTemplates: () => Promise<PolicyTemplateItem[]>;
15
+ getTemplate?: (id: string) => Promise<PolicyTemplateItem>;
16
+ createTemplate: (data: TemplateFormData) => Promise<PolicyTemplateItem | void>;
17
+ updateTemplate: (id: string, data: Partial<TemplateFormData>) => Promise<PolicyTemplateItem | void>;
18
+ deleteTemplate: (id: string) => Promise<void>;
19
+ _isLocalStorage?: boolean;
20
+ }
21
+
22
+ const STORAGE_KEY = "tidecloak_policy_templates";
23
+
24
+ /** localStorage adapter for development */
25
+ export function createLocalStorageTemplateAPI(storageKey = STORAGE_KEY): TemplateAPI {
26
+ const getAll = (): PolicyTemplateItem[] => {
27
+ try {
28
+ const data = localStorage.getItem(storageKey);
29
+ return data ? JSON.parse(data) : [];
30
+ } catch {
31
+ return [];
32
+ }
33
+ };
34
+
35
+ const saveAll = (templates: PolicyTemplateItem[]) => {
36
+ localStorage.setItem(storageKey, JSON.stringify(templates));
37
+ };
38
+
39
+ return {
40
+ _isLocalStorage: true,
41
+
42
+ getTemplates: async () => {
43
+ return getAll();
44
+ },
45
+
46
+ getTemplate: async (id: string) => {
47
+ const templates = getAll();
48
+ const template = templates.find((t) => t.id === id);
49
+ if (!template) throw new Error(`Template not found: ${id}`);
50
+ return template;
51
+ },
52
+
53
+ createTemplate: async (data: TemplateFormData) => {
54
+ const templates = getAll();
55
+ const newTemplate: PolicyTemplateItem = {
56
+ id: crypto.randomUUID(),
57
+ ...data,
58
+ createdBy: "user",
59
+ };
60
+ templates.push(newTemplate);
61
+ saveAll(templates);
62
+ return newTemplate;
63
+ },
64
+
65
+ updateTemplate: async (id: string, data: Partial<TemplateFormData>) => {
66
+ const templates = getAll();
67
+ const index = templates.findIndex((t) => t.id === id);
68
+ if (index === -1) throw new Error(`Template not found: ${id}`);
69
+ templates[index] = { ...templates[index], ...data };
70
+ saveAll(templates);
71
+ return templates[index];
72
+ },
73
+
74
+ deleteTemplate: async (id: string) => {
75
+ const templates = getAll();
76
+ const filtered = templates.filter((t) => t.id !== id);
77
+ if (filtered.length === templates.length) {
78
+ throw new Error(`Template not found: ${id}`);
79
+ }
80
+ saveAll(filtered);
81
+ },
82
+ };
83
+ }
84
+
85
+ export interface TemplatesPageProps<T extends PolicyTemplateItem = PolicyTemplateItem>
86
+ extends Omit<TemplatesPageBaseProps<T>, "fetchData" | "onCreate" | "onUpdate" | "onDelete"> {
87
+ /** Template storage adapter */
88
+ api: TemplateAPI;
89
+ /** Override fetch templates */
90
+ fetchData?: TemplatesPageBaseProps<T>["fetchData"];
91
+ /** Override create */
92
+ onCreate?: TemplatesPageBaseProps<T>["onCreate"];
93
+ /** Override update */
94
+ onUpdate?: TemplatesPageBaseProps<T>["onUpdate"];
95
+ /** Override delete */
96
+ onDelete?: TemplatesPageBaseProps<T>["onDelete"];
97
+ }
98
+
99
+ // Warning banner for localStorage
100
+ function LocalStorageWarning() {
101
+ return (
102
+ <div
103
+ style={{
104
+ backgroundColor: "#fef3c7",
105
+ border: "1px solid #f59e0b",
106
+ borderRadius: "0.375rem",
107
+ padding: "0.75rem 1rem",
108
+ marginBottom: "1rem",
109
+ display: "flex",
110
+ alignItems: "flex-start",
111
+ gap: "0.5rem",
112
+ }}
113
+ >
114
+ <span style={{ fontSize: "1rem" }}>⚠️</span>
115
+ <div style={{ fontSize: "0.875rem", color: "#92400e" }}>
116
+ <strong>Development Mode:</strong> Templates are stored in browser localStorage and will not persist across
117
+ devices or browsers. For production, implement your own{" "}
118
+ <code style={{ backgroundColor: "#fde68a", padding: "0 0.25rem", borderRadius: "0.25rem" }}>TemplateAPI</code>{" "}
119
+ with a backend database.
120
+ </div>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ export function TemplatesPage<T extends PolicyTemplateItem = PolicyTemplateItem>({
126
+ api,
127
+ fetchData: fetchDataProp,
128
+ onCreate: onCreateProp,
129
+ onUpdate: onUpdateProp,
130
+ onDelete: onDeleteProp,
131
+ helpText: helpTextProp,
132
+ ...props
133
+ }: TemplatesPageProps<T>) {
134
+ const isLocalStorage = api._isLocalStorage === true;
135
+
136
+ const fetchData =
137
+ fetchDataProp ||
138
+ (async () => {
139
+ const templates = (await api.getTemplates()) as T[];
140
+ return { templates: templates || [] };
141
+ });
142
+
143
+ const onCreate =
144
+ onCreateProp ||
145
+ (async (data) => {
146
+ await api.createTemplate(data);
147
+ });
148
+
149
+ const onUpdate =
150
+ onUpdateProp ||
151
+ (async (id, data) => {
152
+ await api.updateTemplate(id, data);
153
+ });
154
+
155
+ const onDelete =
156
+ onDeleteProp ||
157
+ (async (id) => {
158
+ await api.deleteTemplate(id);
159
+ });
160
+
161
+ // Show localStorage warning if using localStorage API
162
+ const helpText = isLocalStorage ? (
163
+ <>
164
+ <LocalStorageWarning />
165
+ {helpTextProp}
166
+ </>
167
+ ) : (
168
+ helpTextProp
169
+ );
170
+
171
+ return (
172
+ <TemplatesPageBase<T>
173
+ fetchData={fetchData}
174
+ onCreate={onCreate}
175
+ onUpdate={onUpdate}
176
+ onDelete={onDelete}
177
+ helpText={helpText}
178
+ {...props}
179
+ />
180
+ );
181
+ }