@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.
- package/CHANGELOG.md +87 -0
- package/package.json +31 -0
- package/src/components/NotificationBell.tsx +283 -0
- package/src/components/StrategyCard.tsx +159 -0
- package/src/components/UserChannelCard.tsx +310 -0
- package/src/components/UserMenuItems.tsx +15 -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,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
|
+
});
|