@checkmate-monitor/notification-frontend 0.1.0

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,310 @@
1
+ import { useState } from "react";
2
+ import {
3
+ Link2,
4
+ Link2Off,
5
+ Check,
6
+ X,
7
+ Loader2,
8
+ ChevronDown,
9
+ ChevronUp,
10
+ Send,
11
+ } from "lucide-react";
12
+ import {
13
+ Card,
14
+ Button,
15
+ Badge,
16
+ DynamicForm,
17
+ cn,
18
+ DynamicIcon,
19
+ MarkdownBlock,
20
+ } from "@checkmate-monitor/ui";
21
+
22
+ /**
23
+ * User channel data from getUserDeliveryChannels endpoint
24
+ */
25
+ export interface UserDeliveryChannel {
26
+ strategyId: string;
27
+ displayName: string;
28
+ description?: string;
29
+ icon?: string;
30
+ contactResolution: {
31
+ type: "auth-email" | "auth-provider" | "user-config" | "oauth-link";
32
+ };
33
+ enabled: boolean;
34
+ isConfigured: boolean;
35
+ linkedAt?: Date;
36
+ userConfigSchema?: Record<string, unknown>;
37
+ userConfig?: Record<string, unknown>;
38
+ /** Markdown instructions for users (connection guides, etc.) */
39
+ userInstructions?: string;
40
+ }
41
+
42
+ export interface UserChannelCardProps {
43
+ channel: UserDeliveryChannel;
44
+ onToggle: (strategyId: string, enabled: boolean) => Promise<void>;
45
+ onConnect: (strategyId: string) => Promise<void>;
46
+ onDisconnect: (strategyId: string) => Promise<void>;
47
+ onSaveConfig: (
48
+ strategyId: string,
49
+ config: Record<string, unknown>
50
+ ) => Promise<void>;
51
+ onTest: (strategyId: string) => Promise<{ success: boolean; error?: string }>;
52
+ saving?: boolean;
53
+ connecting?: boolean;
54
+ testing?: boolean;
55
+ }
56
+
57
+ /**
58
+ * User card for managing their notification channel preferences.
59
+ * Shows enable/disable, OAuth connect/disconnect, and user config form.
60
+ */
61
+ export function UserChannelCard({
62
+ channel,
63
+ onToggle,
64
+ onConnect,
65
+ onDisconnect,
66
+ onSaveConfig,
67
+ onTest,
68
+ saving,
69
+ connecting,
70
+ testing,
71
+ }: UserChannelCardProps) {
72
+ const [expanded, setExpanded] = useState(false);
73
+ const [userConfig, setUserConfig] = useState<Record<string, unknown>>(
74
+ channel.userConfig ?? {}
75
+ );
76
+ const [localEnabled, setLocalEnabled] = useState(channel.enabled);
77
+ const [configValid, setConfigValid] = useState(true); // Start true since existing config is valid
78
+
79
+ const requiresOAuth = channel.contactResolution.type === "oauth-link";
80
+ const requiresUserConfig = channel.contactResolution.type === "user-config";
81
+ const isLinked = !!channel.linkedAt;
82
+ const hasUserConfigSchema =
83
+ channel.userConfigSchema &&
84
+ Object.keys(channel.userConfigSchema).length > 0;
85
+
86
+ // Determine if channel can be enabled
87
+ const canEnable = () => {
88
+ if (requiresOAuth && !isLinked) return false;
89
+ if (requiresUserConfig && !channel.isConfigured) return false;
90
+ return true;
91
+ };
92
+
93
+ const handleToggle = async () => {
94
+ const newEnabled = !localEnabled;
95
+ if (!canEnable() && newEnabled) {
96
+ // Can't enable - missing requirements
97
+ return;
98
+ }
99
+ setLocalEnabled(newEnabled);
100
+ await onToggle(channel.strategyId, newEnabled);
101
+ };
102
+
103
+ const handleConnect = async () => {
104
+ await onConnect(channel.strategyId);
105
+ };
106
+
107
+ const handleDisconnect = async () => {
108
+ await onDisconnect(channel.strategyId);
109
+ setLocalEnabled(false);
110
+ };
111
+
112
+ const handleSaveConfig = async () => {
113
+ await onSaveConfig(channel.strategyId, userConfig);
114
+ };
115
+
116
+ // Get status badge
117
+ const getStatusBadge = () => {
118
+ if (requiresOAuth && !isLinked) {
119
+ return (
120
+ <Badge variant="outline" className="text-amber-600 border-amber-600">
121
+ Not Connected
122
+ </Badge>
123
+ );
124
+ }
125
+ if (requiresUserConfig && !channel.isConfigured) {
126
+ return (
127
+ <Badge variant="outline" className="text-amber-600 border-amber-600">
128
+ Setup Required
129
+ </Badge>
130
+ );
131
+ }
132
+ if (localEnabled) {
133
+ return (
134
+ <Badge variant="secondary" className="text-green-600 border-green-600">
135
+ Active
136
+ </Badge>
137
+ );
138
+ }
139
+ return <Badge variant="outline">Disabled</Badge>;
140
+ };
141
+
142
+ return (
143
+ <Card
144
+ className={cn(
145
+ "overflow-hidden transition-all",
146
+ localEnabled && channel.isConfigured ? "border-green-500/30" : ""
147
+ )}
148
+ >
149
+ {/* Header */}
150
+ <div className="flex items-center justify-between p-4">
151
+ <div className="flex items-center gap-3">
152
+ <DynamicIcon
153
+ name={channel.icon}
154
+ className="h-5 w-5 text-muted-foreground"
155
+ />
156
+ <div>
157
+ <div className="flex items-center gap-2">
158
+ <span className="font-medium">{channel.displayName}</span>
159
+ {getStatusBadge()}
160
+ </div>
161
+ {channel.description && (
162
+ <p className="text-sm text-muted-foreground mt-0.5">
163
+ {channel.description}
164
+ </p>
165
+ )}
166
+ {isLinked && channel.linkedAt && (
167
+ <p className="text-xs text-muted-foreground mt-1">
168
+ Connected {new Date(channel.linkedAt).toLocaleDateString()}
169
+ </p>
170
+ )}
171
+ {/* Warning for OAuth strategies about shared targets */}
172
+ {requiresOAuth && isLinked && (
173
+ <p className="text-xs text-amber-600 mt-1">
174
+ ⚠️ Avoid shared targets (group chats) — transactional messages
175
+ (e.g., password resets) may also be sent here.
176
+ </p>
177
+ )}
178
+ </div>
179
+ </div>
180
+
181
+ <div className="flex items-center gap-2">
182
+ {/* OAuth Connect/Disconnect */}
183
+ {requiresOAuth &&
184
+ (isLinked ? (
185
+ <Button
186
+ variant="outline"
187
+ size="sm"
188
+ onClick={() => void handleDisconnect()}
189
+ disabled={saving || connecting}
190
+ className="text-destructive hover:text-destructive"
191
+ >
192
+ {connecting ? (
193
+ <Loader2 className="h-4 w-4 animate-spin" />
194
+ ) : (
195
+ <Link2Off className="h-4 w-4 mr-1" />
196
+ )}
197
+ Disconnect
198
+ </Button>
199
+ ) : (
200
+ <Button
201
+ variant="primary"
202
+ size="sm"
203
+ onClick={() => void handleConnect()}
204
+ disabled={saving || connecting}
205
+ >
206
+ {connecting ? (
207
+ <Loader2 className="h-4 w-4 animate-spin" />
208
+ ) : (
209
+ <Link2 className="h-4 w-4 mr-1" />
210
+ )}
211
+ Connect
212
+ </Button>
213
+ ))}
214
+
215
+ {/* Enable/Disable toggle */}
216
+ {canEnable() && (
217
+ <Button
218
+ variant={localEnabled ? "primary" : "outline"}
219
+ size="sm"
220
+ onClick={() => void handleToggle()}
221
+ disabled={saving}
222
+ className="min-w-[90px]"
223
+ >
224
+ {localEnabled ? (
225
+ <>
226
+ <Check className="h-4 w-4 mr-1" />
227
+ Enabled
228
+ </>
229
+ ) : (
230
+ <>
231
+ <X className="h-4 w-4 mr-1" />
232
+ Disabled
233
+ </>
234
+ )}
235
+ </Button>
236
+ )}
237
+
238
+ {/* Test notification button - only show when channel is configured */}
239
+ {channel.isConfigured && (
240
+ <Button
241
+ variant="ghost"
242
+ size="sm"
243
+ onClick={() => void onTest(channel.strategyId)}
244
+ disabled={testing || saving}
245
+ title="Send test notification"
246
+ >
247
+ {testing ? (
248
+ <Loader2 className="h-4 w-4 animate-spin" />
249
+ ) : (
250
+ <Send className="h-4 w-4" />
251
+ )}
252
+ </Button>
253
+ )}
254
+
255
+ {/* Expand for user config */}
256
+ {hasUserConfigSchema && (
257
+ <Button
258
+ variant="ghost"
259
+ size="sm"
260
+ onClick={() => setExpanded(!expanded)}
261
+ >
262
+ {expanded ? (
263
+ <ChevronUp className="h-4 w-4" />
264
+ ) : (
265
+ <ChevronDown className="h-4 w-4" />
266
+ )}
267
+ </Button>
268
+ )}
269
+ </div>
270
+ </div>
271
+
272
+ {/* User config form */}
273
+ {expanded && hasUserConfigSchema && channel.userConfigSchema && (
274
+ <div className="border-t p-4 bg-muted/30 space-y-4">
275
+ {/* User instructions block */}
276
+ {channel.userInstructions && (
277
+ <div className="p-4 bg-muted/50 rounded-lg border border-border/50">
278
+ <MarkdownBlock size="sm">
279
+ {channel.userInstructions}
280
+ </MarkdownBlock>
281
+ </div>
282
+ )}
283
+
284
+ <DynamicForm
285
+ schema={channel.userConfigSchema}
286
+ value={userConfig}
287
+ onChange={setUserConfig}
288
+ onValidChange={setConfigValid}
289
+ />
290
+ <div className="mt-4 flex justify-end">
291
+ <Button
292
+ onClick={() => void handleSaveConfig()}
293
+ disabled={saving || !configValid}
294
+ size="sm"
295
+ >
296
+ {saving ? "Saving..." : "Save Settings"}
297
+ </Button>
298
+ </div>
299
+ </div>
300
+ )}
301
+
302
+ {/* User instructions when not connected (for oauth-link channels) */}
303
+ {!isLinked && channel.userInstructions && requiresOAuth && (
304
+ <div className="border-t p-4 bg-muted/30">
305
+ <MarkdownBlock size="sm">{channel.userInstructions}</MarkdownBlock>
306
+ </div>
307
+ )}
308
+ </Card>
309
+ );
310
+ }
@@ -0,0 +1,15 @@
1
+ import { Link } from "react-router-dom";
2
+ import { Bell } from "lucide-react";
3
+ import { DropdownMenuItem } from "@checkmate-monitor/ui";
4
+ import { resolveRoute } from "@checkmate-monitor/common";
5
+ import { notificationRoutes } from "@checkmate-monitor/notification-common";
6
+
7
+ export const NotificationUserMenuItems = () => {
8
+ return (
9
+ <Link to={resolveRoute(notificationRoutes.routes.settings)}>
10
+ <DropdownMenuItem icon={<Bell className="h-4 w-4" />}>
11
+ Notification Settings
12
+ </DropdownMenuItem>
13
+ </Link>
14
+ );
15
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,39 @@
1
+ import {
2
+ createFrontendPlugin,
3
+ NavbarSlot,
4
+ UserMenuItemsSlot,
5
+ } from "@checkmate-monitor/frontend-api";
6
+ import {
7
+ notificationRoutes,
8
+ pluginMetadata,
9
+ } from "@checkmate-monitor/notification-common";
10
+ import { NotificationBell } from "./components/NotificationBell";
11
+ import { NotificationsPage } from "./pages/NotificationsPage";
12
+ import { NotificationSettingsPage } from "./pages/NotificationSettingsPage";
13
+ import { NotificationUserMenuItems } from "./components/UserMenuItems";
14
+
15
+ export const notificationPlugin = createFrontendPlugin({
16
+ metadata: pluginMetadata,
17
+ routes: [
18
+ {
19
+ route: notificationRoutes.routes.home,
20
+ element: <NotificationsPage />,
21
+ },
22
+ {
23
+ route: notificationRoutes.routes.settings,
24
+ element: <NotificationSettingsPage />,
25
+ },
26
+ ],
27
+ extensions: [
28
+ {
29
+ id: "notification.navbar.bell",
30
+ slot: NavbarSlot,
31
+ component: NotificationBell,
32
+ },
33
+ {
34
+ id: "notification.user.setting",
35
+ slot: UserMenuItemsSlot,
36
+ component: NotificationUserMenuItems,
37
+ },
38
+ ],
39
+ });