@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.
@@ -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
+ };