@checkstack/notification-frontend 0.0.2
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/CHANGELOG.md +175 -0
- package/package.json +31 -0
- package/src/components/NotificationBell.tsx +283 -0
- package/src/components/StrategyCard.tsx +163 -0
- package/src/components/UserChannelCard.tsx +311 -0
- package/src/components/UserMenuItems.tsx +16 -0
- package/src/index.tsx +39 -0
- package/src/pages/NotificationSettingsPage.tsx +501 -0
- package/src/pages/NotificationsPage.tsx +300 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Bell, Clock, Zap, Send } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
PageLayout,
|
|
5
|
+
Card,
|
|
6
|
+
Button,
|
|
7
|
+
useToast,
|
|
8
|
+
SectionHeader,
|
|
9
|
+
DynamicForm,
|
|
10
|
+
} from "@checkstack/ui";
|
|
11
|
+
import {
|
|
12
|
+
useApi,
|
|
13
|
+
rpcApiRef,
|
|
14
|
+
permissionApiRef,
|
|
15
|
+
} from "@checkstack/frontend-api";
|
|
16
|
+
import type { EnrichedSubscription } from "@checkstack/notification-common";
|
|
17
|
+
import {
|
|
18
|
+
NotificationApi,
|
|
19
|
+
permissions,
|
|
20
|
+
} from "@checkstack/notification-common";
|
|
21
|
+
import {
|
|
22
|
+
StrategyCard,
|
|
23
|
+
type DeliveryStrategy,
|
|
24
|
+
} from "../components/StrategyCard";
|
|
25
|
+
import {
|
|
26
|
+
UserChannelCard,
|
|
27
|
+
type UserDeliveryChannel,
|
|
28
|
+
} from "../components/UserChannelCard";
|
|
29
|
+
|
|
30
|
+
export const NotificationSettingsPage = () => {
|
|
31
|
+
const rpcApi = useApi(rpcApiRef);
|
|
32
|
+
const permissionApi = useApi(permissionApiRef);
|
|
33
|
+
const notificationClient = rpcApi.forPlugin(NotificationApi);
|
|
34
|
+
const toast = useToast();
|
|
35
|
+
|
|
36
|
+
// Check if user has admin permission
|
|
37
|
+
const { allowed: isAdmin } = permissionApi.usePermission(
|
|
38
|
+
permissions.notificationAdmin.id
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Retention settings state
|
|
42
|
+
const [retentionSchema, setRetentionSchema] = useState<
|
|
43
|
+
Record<string, unknown> | undefined
|
|
44
|
+
>();
|
|
45
|
+
const [retentionSettings, setRetentionSettings] = useState<
|
|
46
|
+
Record<string, unknown>
|
|
47
|
+
>({
|
|
48
|
+
retentionDays: 30,
|
|
49
|
+
enabled: false,
|
|
50
|
+
});
|
|
51
|
+
const [retentionLoading, setRetentionLoading] = useState(true);
|
|
52
|
+
const [retentionSaving, setRetentionSaving] = useState(false);
|
|
53
|
+
const [retentionValid, setRetentionValid] = useState(true);
|
|
54
|
+
|
|
55
|
+
// Subscription state - now uses enriched subscriptions only
|
|
56
|
+
const [subscriptions, setSubscriptions] = useState<EnrichedSubscription[]>(
|
|
57
|
+
[]
|
|
58
|
+
);
|
|
59
|
+
const [subsLoading, setSubsLoading] = useState(true);
|
|
60
|
+
|
|
61
|
+
// Delivery strategies state (admin only)
|
|
62
|
+
const [strategies, setStrategies] = useState<DeliveryStrategy[]>([]);
|
|
63
|
+
const [strategiesLoading, setStrategiesLoading] = useState(true);
|
|
64
|
+
const [strategySaving, setStrategySaving] = useState<string | undefined>();
|
|
65
|
+
|
|
66
|
+
// User channels state
|
|
67
|
+
const [userChannels, setUserChannels] = useState<UserDeliveryChannel[]>([]);
|
|
68
|
+
const [channelsLoading, setChannelsLoading] = useState(true);
|
|
69
|
+
const [channelSaving, setChannelSaving] = useState<string | undefined>();
|
|
70
|
+
const [channelConnecting, setChannelConnecting] = useState<
|
|
71
|
+
string | undefined
|
|
72
|
+
>();
|
|
73
|
+
const [channelTesting, setChannelTesting] = useState<string | undefined>();
|
|
74
|
+
|
|
75
|
+
// Fetch retention settings and schema (admin only)
|
|
76
|
+
const fetchRetentionData = useCallback(async () => {
|
|
77
|
+
if (!isAdmin) {
|
|
78
|
+
setRetentionLoading(false);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const [schema, settings] = await Promise.all([
|
|
83
|
+
notificationClient.getRetentionSchema(),
|
|
84
|
+
notificationClient.getRetentionSettings(),
|
|
85
|
+
]);
|
|
86
|
+
setRetentionSchema(schema as Record<string, unknown>);
|
|
87
|
+
setRetentionSettings(settings);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message =
|
|
90
|
+
error instanceof Error
|
|
91
|
+
? error.message
|
|
92
|
+
: "Failed to load retention settings";
|
|
93
|
+
toast.error(message);
|
|
94
|
+
} finally {
|
|
95
|
+
setRetentionLoading(false);
|
|
96
|
+
}
|
|
97
|
+
}, [notificationClient, isAdmin, toast]);
|
|
98
|
+
|
|
99
|
+
// Fetch subscriptions only (no groups needed)
|
|
100
|
+
const fetchSubscriptionData = useCallback(async () => {
|
|
101
|
+
try {
|
|
102
|
+
const subsData = await notificationClient.getSubscriptions();
|
|
103
|
+
setSubscriptions(subsData);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const message =
|
|
106
|
+
error instanceof Error
|
|
107
|
+
? error.message
|
|
108
|
+
: "Failed to fetch subscriptions";
|
|
109
|
+
toast.error(message);
|
|
110
|
+
} finally {
|
|
111
|
+
setSubsLoading(false);
|
|
112
|
+
}
|
|
113
|
+
}, [notificationClient, toast]);
|
|
114
|
+
|
|
115
|
+
// Fetch delivery strategies (admin only)
|
|
116
|
+
const fetchStrategies = useCallback(async () => {
|
|
117
|
+
if (!isAdmin) {
|
|
118
|
+
setStrategiesLoading(false);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const data = await notificationClient.getDeliveryStrategies();
|
|
123
|
+
setStrategies(data as DeliveryStrategy[]);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const message =
|
|
126
|
+
error instanceof Error
|
|
127
|
+
? error.message
|
|
128
|
+
: "Failed to load delivery channels";
|
|
129
|
+
toast.error(message);
|
|
130
|
+
} finally {
|
|
131
|
+
setStrategiesLoading(false);
|
|
132
|
+
}
|
|
133
|
+
}, [notificationClient, isAdmin, toast]);
|
|
134
|
+
|
|
135
|
+
// Fetch user delivery channels
|
|
136
|
+
const fetchUserChannels = useCallback(async () => {
|
|
137
|
+
try {
|
|
138
|
+
const data = await notificationClient.getUserDeliveryChannels();
|
|
139
|
+
setUserChannels(data as UserDeliveryChannel[]);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const message =
|
|
142
|
+
error instanceof Error ? error.message : "Failed to load your channels";
|
|
143
|
+
toast.error(message);
|
|
144
|
+
} finally {
|
|
145
|
+
setChannelsLoading(false);
|
|
146
|
+
}
|
|
147
|
+
}, [notificationClient, toast]);
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
void fetchRetentionData();
|
|
151
|
+
void fetchSubscriptionData();
|
|
152
|
+
void fetchStrategies();
|
|
153
|
+
void fetchUserChannels();
|
|
154
|
+
}, [
|
|
155
|
+
fetchRetentionData,
|
|
156
|
+
fetchSubscriptionData,
|
|
157
|
+
fetchStrategies,
|
|
158
|
+
fetchUserChannels,
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const handleSaveRetention = async () => {
|
|
162
|
+
try {
|
|
163
|
+
setRetentionSaving(true);
|
|
164
|
+
await notificationClient.setRetentionSettings(
|
|
165
|
+
retentionSettings as { enabled: boolean; retentionDays: number }
|
|
166
|
+
);
|
|
167
|
+
toast.success("Retention settings saved");
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const message =
|
|
170
|
+
error instanceof Error ? error.message : "Failed to save settings";
|
|
171
|
+
toast.error(message);
|
|
172
|
+
} finally {
|
|
173
|
+
setRetentionSaving(false);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const handleUnsubscribe = async (groupId: string) => {
|
|
178
|
+
try {
|
|
179
|
+
await notificationClient.unsubscribe({ groupId });
|
|
180
|
+
setSubscriptions((prev) => prev.filter((s) => s.groupId !== groupId));
|
|
181
|
+
toast.success("Unsubscribed successfully");
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const message =
|
|
184
|
+
error instanceof Error ? error.message : "Failed to unsubscribe";
|
|
185
|
+
toast.error(message);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Handle strategy update (enabled state and config)
|
|
190
|
+
const handleStrategyUpdate = async (
|
|
191
|
+
strategyId: string,
|
|
192
|
+
enabled: boolean,
|
|
193
|
+
config?: Record<string, unknown>,
|
|
194
|
+
layoutConfig?: Record<string, unknown>
|
|
195
|
+
) => {
|
|
196
|
+
try {
|
|
197
|
+
setStrategySaving(strategyId);
|
|
198
|
+
await notificationClient.updateDeliveryStrategy({
|
|
199
|
+
strategyId,
|
|
200
|
+
enabled,
|
|
201
|
+
config,
|
|
202
|
+
layoutConfig,
|
|
203
|
+
});
|
|
204
|
+
// Update local state
|
|
205
|
+
setStrategies((prev) =>
|
|
206
|
+
prev.map((s) =>
|
|
207
|
+
s.qualifiedId === strategyId
|
|
208
|
+
? { ...s, enabled, config, layoutConfig }
|
|
209
|
+
: s
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
toast.success(`${enabled ? "Enabled" : "Disabled"} delivery channel`);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const message =
|
|
215
|
+
error instanceof Error ? error.message : "Failed to update channel";
|
|
216
|
+
toast.error(message);
|
|
217
|
+
} finally {
|
|
218
|
+
setStrategySaving(undefined);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Handle user channel toggle
|
|
223
|
+
const handleChannelToggle = async (strategyId: string, enabled: boolean) => {
|
|
224
|
+
try {
|
|
225
|
+
setChannelSaving(strategyId);
|
|
226
|
+
await notificationClient.setUserDeliveryPreference({
|
|
227
|
+
strategyId,
|
|
228
|
+
enabled,
|
|
229
|
+
});
|
|
230
|
+
setUserChannels((prev) =>
|
|
231
|
+
prev.map((c) => (c.strategyId === strategyId ? { ...c, enabled } : c))
|
|
232
|
+
);
|
|
233
|
+
toast.success(`${enabled ? "Enabled" : "Disabled"} notification channel`);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
const message =
|
|
236
|
+
error instanceof Error ? error.message : "Failed to update preference";
|
|
237
|
+
toast.error(message);
|
|
238
|
+
} finally {
|
|
239
|
+
setChannelSaving(undefined);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Handle OAuth connect
|
|
244
|
+
const handleChannelConnect = async (strategyId: string) => {
|
|
245
|
+
try {
|
|
246
|
+
setChannelConnecting(strategyId);
|
|
247
|
+
const { authUrl } = await notificationClient.getDeliveryOAuthUrl({
|
|
248
|
+
strategyId,
|
|
249
|
+
returnUrl: globalThis.location.pathname,
|
|
250
|
+
});
|
|
251
|
+
// Redirect to OAuth provider
|
|
252
|
+
globalThis.location.href = authUrl;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const message =
|
|
255
|
+
error instanceof Error ? error.message : "Failed to start OAuth flow";
|
|
256
|
+
toast.error(message);
|
|
257
|
+
setChannelConnecting(undefined);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Handle OAuth disconnect
|
|
262
|
+
const handleChannelDisconnect = async (strategyId: string) => {
|
|
263
|
+
try {
|
|
264
|
+
setChannelSaving(strategyId);
|
|
265
|
+
await notificationClient.unlinkDeliveryChannel({ strategyId });
|
|
266
|
+
setUserChannels((prev) =>
|
|
267
|
+
prev.map((c) =>
|
|
268
|
+
c.strategyId === strategyId
|
|
269
|
+
? { ...c, linkedAt: undefined, enabled: false, isConfigured: false }
|
|
270
|
+
: c
|
|
271
|
+
)
|
|
272
|
+
);
|
|
273
|
+
toast.success("Disconnected notification channel");
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const message =
|
|
276
|
+
error instanceof Error ? error.message : "Failed to disconnect";
|
|
277
|
+
toast.error(message);
|
|
278
|
+
} finally {
|
|
279
|
+
setChannelSaving(undefined);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Handle user config save
|
|
284
|
+
const handleChannelConfigSave = async (
|
|
285
|
+
strategyId: string,
|
|
286
|
+
userConfig: Record<string, unknown>
|
|
287
|
+
) => {
|
|
288
|
+
try {
|
|
289
|
+
setChannelSaving(strategyId);
|
|
290
|
+
await notificationClient.setUserDeliveryPreference({
|
|
291
|
+
strategyId,
|
|
292
|
+
enabled:
|
|
293
|
+
userChannels.find((c) => c.strategyId === strategyId)?.enabled ??
|
|
294
|
+
false,
|
|
295
|
+
userConfig,
|
|
296
|
+
});
|
|
297
|
+
setUserChannels((prev) =>
|
|
298
|
+
prev.map((c) =>
|
|
299
|
+
c.strategyId === strategyId
|
|
300
|
+
? { ...c, userConfig, isConfigured: true }
|
|
301
|
+
: c
|
|
302
|
+
)
|
|
303
|
+
);
|
|
304
|
+
toast.success("Saved channel settings");
|
|
305
|
+
} catch (error) {
|
|
306
|
+
const message =
|
|
307
|
+
error instanceof Error ? error.message : "Failed to save settings";
|
|
308
|
+
toast.error(message);
|
|
309
|
+
} finally {
|
|
310
|
+
setChannelSaving(undefined);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<PageLayout title="Notification Settings" loading={subsLoading}>
|
|
316
|
+
<div className="space-y-8">
|
|
317
|
+
{/* Your Notification Channels - All users */}
|
|
318
|
+
<section>
|
|
319
|
+
<SectionHeader
|
|
320
|
+
title="Your Notification Channels"
|
|
321
|
+
description="Manage how you receive notifications. Connect accounts and enable/disable channels."
|
|
322
|
+
icon={<Send className="h-5 w-5" />}
|
|
323
|
+
/>
|
|
324
|
+
{channelsLoading ? (
|
|
325
|
+
<Card className="p-4">
|
|
326
|
+
<div className="text-center py-4 text-muted-foreground">
|
|
327
|
+
Loading your channels...
|
|
328
|
+
</div>
|
|
329
|
+
</Card>
|
|
330
|
+
) : userChannels.length === 0 ? (
|
|
331
|
+
<Card className="p-4">
|
|
332
|
+
<div className="text-center py-4 text-muted-foreground">
|
|
333
|
+
No notification channels available. Contact your administrator
|
|
334
|
+
to enable delivery channels.
|
|
335
|
+
</div>
|
|
336
|
+
</Card>
|
|
337
|
+
) : (
|
|
338
|
+
<div className="space-y-3">
|
|
339
|
+
{userChannels.map((channel) => (
|
|
340
|
+
<UserChannelCard
|
|
341
|
+
key={channel.strategyId}
|
|
342
|
+
channel={channel}
|
|
343
|
+
onToggle={handleChannelToggle}
|
|
344
|
+
onConnect={handleChannelConnect}
|
|
345
|
+
onDisconnect={handleChannelDisconnect}
|
|
346
|
+
onSaveConfig={handleChannelConfigSave}
|
|
347
|
+
onTest={async (strategyId) => {
|
|
348
|
+
setChannelTesting(strategyId);
|
|
349
|
+
try {
|
|
350
|
+
const result =
|
|
351
|
+
await notificationClient.sendTestNotification({
|
|
352
|
+
strategyId,
|
|
353
|
+
});
|
|
354
|
+
if (!result.success) {
|
|
355
|
+
alert(`Test failed: ${result.error}`);
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
} finally {
|
|
359
|
+
setChannelTesting(undefined);
|
|
360
|
+
}
|
|
361
|
+
}}
|
|
362
|
+
saving={channelSaving === channel.strategyId}
|
|
363
|
+
connecting={channelConnecting === channel.strategyId}
|
|
364
|
+
testing={channelTesting === channel.strategyId}
|
|
365
|
+
/>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
</section>
|
|
370
|
+
|
|
371
|
+
{/* Subscription Management - Shows current subscriptions */}
|
|
372
|
+
<section>
|
|
373
|
+
<SectionHeader
|
|
374
|
+
title="Your Subscriptions"
|
|
375
|
+
description="Manage your notification subscriptions. Subscriptions are created by plugins and services."
|
|
376
|
+
icon={<Bell className="h-5 w-5" />}
|
|
377
|
+
/>
|
|
378
|
+
<Card className="p-4">
|
|
379
|
+
{subscriptions.length === 0 ? (
|
|
380
|
+
<div className="text-center py-4 text-muted-foreground">
|
|
381
|
+
No active subscriptions
|
|
382
|
+
</div>
|
|
383
|
+
) : (
|
|
384
|
+
<div className="space-y-3">
|
|
385
|
+
{subscriptions.map((sub) => (
|
|
386
|
+
<div
|
|
387
|
+
key={sub.groupId}
|
|
388
|
+
className="flex items-center justify-between py-2 border-b last:border-0"
|
|
389
|
+
>
|
|
390
|
+
<div>
|
|
391
|
+
<div className="font-medium">{sub.groupName}</div>
|
|
392
|
+
<div className="text-sm text-muted-foreground">
|
|
393
|
+
{sub.groupDescription}
|
|
394
|
+
</div>
|
|
395
|
+
<div className="text-xs text-muted-foreground mt-1">
|
|
396
|
+
From: {sub.ownerPlugin}
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
<Button
|
|
400
|
+
variant="outline"
|
|
401
|
+
size="sm"
|
|
402
|
+
onClick={() => void handleUnsubscribe(sub.groupId)}
|
|
403
|
+
>
|
|
404
|
+
Unsubscribe
|
|
405
|
+
</Button>
|
|
406
|
+
</div>
|
|
407
|
+
))}
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
</Card>
|
|
411
|
+
</section>
|
|
412
|
+
|
|
413
|
+
{/* Admin Section Divider */}
|
|
414
|
+
{isAdmin && (
|
|
415
|
+
<div className="relative py-4">
|
|
416
|
+
<div className="absolute inset-0 flex items-center">
|
|
417
|
+
<div className="w-full border-t border-border" />
|
|
418
|
+
</div>
|
|
419
|
+
<div className="relative flex justify-center">
|
|
420
|
+
<span className="bg-background px-4 text-sm text-muted-foreground flex items-center gap-2">
|
|
421
|
+
<Zap className="h-4 w-4" />
|
|
422
|
+
Admin Settings
|
|
423
|
+
</span>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
|
|
428
|
+
{/* Delivery Channels - Admin only */}
|
|
429
|
+
{isAdmin && (
|
|
430
|
+
<section>
|
|
431
|
+
<SectionHeader
|
|
432
|
+
title="Delivery Channels"
|
|
433
|
+
description="Configure how notifications are delivered to users (admin only)"
|
|
434
|
+
icon={<Zap className="h-5 w-5" />}
|
|
435
|
+
/>
|
|
436
|
+
{strategiesLoading ? (
|
|
437
|
+
<Card className="p-4">
|
|
438
|
+
<div className="text-center py-4 text-muted-foreground">
|
|
439
|
+
Loading delivery channels...
|
|
440
|
+
</div>
|
|
441
|
+
</Card>
|
|
442
|
+
) : strategies.length === 0 ? (
|
|
443
|
+
<Card className="p-4">
|
|
444
|
+
<div className="text-center py-4 text-muted-foreground">
|
|
445
|
+
No delivery channels registered. Plugins can register delivery
|
|
446
|
+
strategies to enable additional notification methods.
|
|
447
|
+
</div>
|
|
448
|
+
</Card>
|
|
449
|
+
) : (
|
|
450
|
+
<div className="space-y-3">
|
|
451
|
+
{strategies.map((strategy) => (
|
|
452
|
+
<StrategyCard
|
|
453
|
+
key={strategy.qualifiedId}
|
|
454
|
+
strategy={strategy}
|
|
455
|
+
onUpdate={handleStrategyUpdate}
|
|
456
|
+
saving={strategySaving === strategy.qualifiedId}
|
|
457
|
+
/>
|
|
458
|
+
))}
|
|
459
|
+
</div>
|
|
460
|
+
)}
|
|
461
|
+
</section>
|
|
462
|
+
)}
|
|
463
|
+
|
|
464
|
+
{/* Retention Policy - Admin only */}
|
|
465
|
+
{isAdmin && retentionSchema && (
|
|
466
|
+
<section>
|
|
467
|
+
<SectionHeader
|
|
468
|
+
title="Retention Policy"
|
|
469
|
+
description="Configure how long notifications are kept (admin only)"
|
|
470
|
+
icon={<Clock className="h-5 w-5" />}
|
|
471
|
+
/>
|
|
472
|
+
<Card className="p-4">
|
|
473
|
+
{retentionLoading ? (
|
|
474
|
+
<div className="text-center py-4 text-muted-foreground">
|
|
475
|
+
Loading...
|
|
476
|
+
</div>
|
|
477
|
+
) : (
|
|
478
|
+
<div className="space-y-4">
|
|
479
|
+
<DynamicForm
|
|
480
|
+
schema={retentionSchema}
|
|
481
|
+
value={retentionSettings}
|
|
482
|
+
onChange={setRetentionSettings}
|
|
483
|
+
onValidChange={setRetentionValid}
|
|
484
|
+
/>
|
|
485
|
+
<Button
|
|
486
|
+
onClick={() => {
|
|
487
|
+
void handleSaveRetention();
|
|
488
|
+
}}
|
|
489
|
+
disabled={retentionSaving || !retentionValid}
|
|
490
|
+
>
|
|
491
|
+
{retentionSaving ? "Saving..." : "Save Settings"}
|
|
492
|
+
</Button>
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
</Card>
|
|
496
|
+
</section>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
</PageLayout>
|
|
500
|
+
);
|
|
501
|
+
};
|