@payez/next-mvp 4.8.1 → 4.8.3

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 (314) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/api-handlers/admin/audit.js +13 -21
  4. package/dist/api-handlers/admin/sessions.js +59 -45
  5. package/dist/api-handlers/admin/stats.js +1 -1
  6. package/dist/api-handlers/admin/users.js +15 -23
  7. package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
  8. package/dist/api-handlers/admin/vibe-data.js +5 -0
  9. package/dist/components/account/MobileNavDrawer.js +18 -18
  10. package/dist/components/account/UserAvatarMenu.js +1 -1
  11. package/dist/components/admin/AlertSettingsTab.js +40 -40
  12. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  13. package/dist/lib/session-store.js +21 -21
  14. package/dist/lib/startup-init.js +19 -19
  15. package/dist/pages/login/page.js +28 -28
  16. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  17. package/dist/vibe/client.d.ts +1 -1
  18. package/dist/vibe/client.js +2 -1
  19. package/dist/vibe/filters.d.ts +36 -0
  20. package/dist/vibe/filters.js +47 -0
  21. package/dist/vibe/generic.js +2 -1
  22. package/dist/vibe/sessions.js +28 -3
  23. package/package.json +2 -2
  24. package/scripts/check-publish-eol.mjs +33 -15
  25. package/src/api/auth-handler.ts +550 -550
  26. package/src/api/index.ts +17 -17
  27. package/src/api-handlers/account/change-password.ts +170 -170
  28. package/src/api-handlers/account/masked-info.ts +46 -46
  29. package/src/api-handlers/account/profile.ts +99 -99
  30. package/src/api-handlers/account/recovery/forward.ts +117 -117
  31. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  32. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  33. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  34. package/src/api-handlers/account/reset-password.ts +87 -87
  35. package/src/api-handlers/account/send-code.ts +70 -70
  36. package/src/api-handlers/account/update-phone.ts +84 -84
  37. package/src/api-handlers/account/validate-password.ts +139 -139
  38. package/src/api-handlers/account/verify-email.ts +134 -134
  39. package/src/api-handlers/account/verify-sms.ts +134 -134
  40. package/src/api-handlers/admin/analytics.ts +452 -452
  41. package/src/api-handlers/admin/audit.ts +223 -232
  42. package/src/api-handlers/admin/index.ts +63 -63
  43. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  44. package/src/api-handlers/admin/sessions.ts +341 -327
  45. package/src/api-handlers/admin/site-logs.ts +388 -388
  46. package/src/api-handlers/admin/stats.ts +260 -260
  47. package/src/api-handlers/admin/users.ts +240 -251
  48. package/src/api-handlers/admin/vibe-data.ts +338 -333
  49. package/src/api-handlers/anon/preferences.ts +123 -123
  50. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  51. package/src/api-handlers/auth/credential-login.ts +948 -948
  52. package/src/api-handlers/auth/jwks.ts +31 -31
  53. package/src/api-handlers/auth/login.ts +252 -252
  54. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  55. package/src/api-handlers/auth/refresh.ts +829 -829
  56. package/src/api-handlers/auth/signout.ts +201 -201
  57. package/src/api-handlers/auth/status.ts +23 -23
  58. package/src/api-handlers/auth/update-session.ts +144 -144
  59. package/src/api-handlers/auth/validate.ts +45 -45
  60. package/src/api-handlers/auth/verify-code.ts +116 -116
  61. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  62. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  63. package/src/api-handlers/mfa/methods.ts +47 -47
  64. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  65. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  66. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  67. package/src/api-handlers/oauth/resume.ts +182 -182
  68. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  69. package/src/api-handlers/onboarding/set-password.ts +21 -21
  70. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  71. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  72. package/src/api-handlers/passkeys/list.ts +32 -32
  73. package/src/api-handlers/passkeys/manage.ts +55 -55
  74. package/src/api-handlers/passkeys/register-options.ts +43 -43
  75. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  76. package/src/api-handlers/session/refresh-viability.ts +56 -56
  77. package/src/api-handlers/session/viability.ts +238 -238
  78. package/src/api-handlers/test/force-expire.ts +167 -167
  79. package/src/auth/auth-decision.ts +292 -292
  80. package/src/auth/better-auth.ts +1688 -1688
  81. package/src/auth/idp-oauth.ts +310 -310
  82. package/src/auth/password-reset-bridge.ts +98 -98
  83. package/src/auth/revocation-fields.ts +16 -16
  84. package/src/auth/route-config.ts +236 -236
  85. package/src/auth/safe-callback.ts +199 -199
  86. package/src/auth/session-cookie-clear.ts +30 -30
  87. package/src/auth/session-reset-link.ts +159 -159
  88. package/src/auth/session-response-sanitizer.ts +151 -151
  89. package/src/auth/types/auth-types.ts +555 -555
  90. package/src/auth/types/index.ts +7 -7
  91. package/src/auth/unauthenticated-routes.ts +2 -2
  92. package/src/auth/utils/idp-client.ts +364 -364
  93. package/src/auth/utils/index.ts +6 -6
  94. package/src/auth/utils/token-utils.ts +243 -243
  95. package/src/client/AuthContext.tsx +192 -192
  96. package/src/client/better-auth-client.ts +70 -70
  97. package/src/client/fetch-with-auth.ts +47 -47
  98. package/src/client/fetchWithSession.ts +21 -21
  99. package/src/client/index.ts +13 -13
  100. package/src/client/useAnonSession.ts +131 -131
  101. package/src/components/SessionSync.tsx +138 -138
  102. package/src/components/SignalRHealthCheck.tsx +131 -131
  103. package/src/components/account/MobileNavDrawer.tsx +305 -305
  104. package/src/components/account/UserAvatarMenu.tsx +277 -277
  105. package/src/components/account/index.ts +13 -13
  106. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  107. package/src/components/admin/AnalyticsTab.tsx +703 -703
  108. package/src/components/admin/DataBrowserTab.tsx +508 -508
  109. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  110. package/src/components/admin/SessionsTab.tsx +421 -421
  111. package/src/components/admin/StatsTab.tsx +379 -379
  112. package/src/components/admin/VibeAdminContext.tsx +87 -87
  113. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  114. package/src/components/admin/index.ts +59 -59
  115. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  116. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  117. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  118. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  119. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  120. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  121. package/src/components/recovery/CompleteStep.tsx +36 -36
  122. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  123. package/src/components/recovery/RequestedStep.tsx +30 -30
  124. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  125. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  126. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  127. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  128. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  129. package/src/components/ui/BetaBadge.tsx +58 -58
  130. package/src/components/ui/Footer.tsx +93 -93
  131. package/src/config/env.ts +55 -55
  132. package/src/config/logger.ts +62 -62
  133. package/src/config/logging-config.ts +82 -82
  134. package/src/config/unauthenticated-routes.ts +19 -19
  135. package/src/config/vibe-log-transport.ts +264 -264
  136. package/src/edge/internal-api-url.ts +65 -65
  137. package/src/edge/middleware.ts +42 -42
  138. package/src/hooks/useAuth.ts +117 -117
  139. package/src/hooks/useAuthSettings.ts +109 -109
  140. package/src/hooks/useAvailableProviders.ts +143 -143
  141. package/src/hooks/usePasswordValidation.ts +127 -127
  142. package/src/hooks/useProfile.ts +82 -82
  143. package/src/hooks/usePublicAuthSettings.ts +171 -171
  144. package/src/hooks/useSessionExpiration.ts +101 -101
  145. package/src/hooks/useViabilitySession.ts +337 -337
  146. package/src/index.ts +64 -64
  147. package/src/lib/anon-session.ts +213 -213
  148. package/src/lib/api-handler.ts +643 -643
  149. package/src/lib/app-slug.ts +228 -228
  150. package/src/lib/authorize-resume.ts +172 -172
  151. package/src/lib/bearer-fetch.ts +31 -31
  152. package/src/lib/client-key.ts +88 -88
  153. package/src/lib/code-step-copy.ts +83 -83
  154. package/src/lib/credential-sign-in-client.ts +101 -101
  155. package/src/lib/demo-mode.ts +18 -18
  156. package/src/lib/device-id.ts +67 -67
  157. package/src/lib/ensure-fresh-access-token.ts +509 -509
  158. package/src/lib/geolocation.ts +265 -265
  159. package/src/lib/idp-client-config.ts +1005 -1005
  160. package/src/lib/idp-fetch.ts +169 -169
  161. package/src/lib/idp-url-guard.ts +37 -37
  162. package/src/lib/internal-api.ts +171 -171
  163. package/src/lib/jwt-decode-client.ts +45 -45
  164. package/src/lib/jwt-decode.ts +82 -82
  165. package/src/lib/lock-window.ts +49 -49
  166. package/src/lib/login-error-message.ts +41 -41
  167. package/src/lib/never-in-response.ts +64 -64
  168. package/src/lib/nextauth-secret.ts +168 -168
  169. package/src/lib/onboarding-proxy.ts +57 -57
  170. package/src/lib/password-rules.ts +50 -50
  171. package/src/lib/rate-limit-service.ts +9 -9
  172. package/src/lib/redis.ts +105 -105
  173. package/src/lib/refresh-token-validator.ts +64 -64
  174. package/src/lib/roles.ts +177 -177
  175. package/src/lib/safe-error-response.ts +149 -149
  176. package/src/lib/secret-validation.ts +8 -8
  177. package/src/lib/session-cookie-shape.ts +185 -185
  178. package/src/lib/session-store.ts +1092 -1092
  179. package/src/lib/session.ts +34 -34
  180. package/src/lib/single-flight.ts +64 -64
  181. package/src/lib/site-logger.ts +245 -245
  182. package/src/lib/standardized-client-api.ts +901 -901
  183. package/src/lib/startup-init.ts +276 -276
  184. package/src/lib/test-aware-get-token.ts +41 -41
  185. package/src/lib/token-expiry.ts +40 -40
  186. package/src/lib/token-lifecycle.ts +546 -546
  187. package/src/lib/token-log-id.ts +69 -69
  188. package/src/lib/twofa-rejection.ts +91 -91
  189. package/src/lib/types/api-responses.ts +336 -336
  190. package/src/lib/user-agent-parser.ts +252 -252
  191. package/src/logging/api/admin-analytics.ts +51 -51
  192. package/src/logging/api/audit-log.ts +53 -53
  193. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  194. package/src/logging/components/AuditLogViewer.tsx +125 -125
  195. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  196. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  197. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  198. package/src/logging/hooks/useAuditLog.ts +24 -24
  199. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  200. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  201. package/src/logging/index.ts +18 -18
  202. package/src/logging/types/analytics.ts +81 -81
  203. package/src/logging/types/audit.ts +31 -31
  204. package/src/logging/types/index.ts +3 -3
  205. package/src/middleware/create-middleware.ts +1141 -1141
  206. package/src/middleware/rbac-check.ts +279 -279
  207. package/src/middleware/twofa-presets.ts +224 -224
  208. package/src/models/DecodedAccessToken.ts +16 -16
  209. package/src/models/SessionModel.ts +331 -331
  210. package/src/pages/admin-login/page.tsx +111 -111
  211. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  212. package/src/pages/admin-page-permissions/index.ts +7 -7
  213. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  214. package/src/pages/admin-roles/index.ts +9 -9
  215. package/src/pages/admin-roles/modals.tsx +469 -469
  216. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  217. package/src/pages/client-admin/index.ts +33 -33
  218. package/src/pages/coming-soon/page.tsx +83 -83
  219. package/src/pages/login/page.tsx +338 -338
  220. package/src/pages/onboarding/index.ts +11 -11
  221. package/src/pages/onboarding/page.tsx +212 -212
  222. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  223. package/src/pages/profile/index.ts +9 -9
  224. package/src/pages/profile/page.tsx +166 -166
  225. package/src/pages/recovery/page.tsx +267 -267
  226. package/src/pages/roles/MyRolesPage.tsx +211 -211
  227. package/src/pages/roles/components.tsx +294 -294
  228. package/src/pages/roles/index.ts +17 -17
  229. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  230. package/src/pages/security/index.ts +12 -12
  231. package/src/pages/security/page.tsx +507 -507
  232. package/src/pages/service-unavailable/page.tsx +63 -63
  233. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  234. package/src/pages/settings/index.ts +9 -9
  235. package/src/pages/settings/page.tsx +47 -47
  236. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  237. package/src/pages/showcase/index.ts +13 -13
  238. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  239. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  240. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  241. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  242. package/src/pages/test-env/guarded.tsx +81 -81
  243. package/src/pages/test-env/index.ts +77 -77
  244. package/src/pages/test-env/redact-credentials.ts +96 -96
  245. package/src/pages/verify-code/page.tsx +746 -746
  246. package/src/routes/account/index.ts +31 -31
  247. package/src/routes/account/masked-info.ts +36 -36
  248. package/src/routes/account/send-code.ts +39 -39
  249. package/src/routes/account/update-phone.ts +13 -13
  250. package/src/routes/account/verify-email.ts +40 -40
  251. package/src/routes/account/verify-sms.ts +40 -40
  252. package/src/routes/auth/credential-login-email-code.ts +6 -6
  253. package/src/routes/auth/credential-login.ts +6 -6
  254. package/src/routes/auth/index.ts +22 -22
  255. package/src/routes/auth/logout.ts +111 -111
  256. package/src/routes/auth/nextauth.ts +45 -45
  257. package/src/routes/auth/passkey-signin.ts +13 -13
  258. package/src/routes/auth/refresh.ts +77 -77
  259. package/src/routes/auth/session-reset.ts +37 -37
  260. package/src/routes/auth/session.ts +261 -261
  261. package/src/routes/auth/settings.ts +181 -181
  262. package/src/routes/auth/viability.ts +519 -519
  263. package/src/routes/index.ts +17 -17
  264. package/src/routes/mfa/backup-code-verify.ts +1 -1
  265. package/src/routes/mfa/backup-codes.ts +10 -10
  266. package/src/routes/mfa/methods.ts +22 -22
  267. package/src/routes/mfa/totp-confirm.ts +10 -10
  268. package/src/routes/mfa/totp-enroll.ts +10 -10
  269. package/src/routes/mfa/totp-verify.ts +14 -14
  270. package/src/routes/onboarding/index.ts +15 -15
  271. package/src/routes/onboarding/send-email-code.ts +2 -2
  272. package/src/routes/onboarding/set-password.ts +2 -2
  273. package/src/routes/onboarding/verify-code.ts +2 -2
  274. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  275. package/src/routes/passkeys/list.ts +10 -10
  276. package/src/routes/passkeys/manage.ts +23 -23
  277. package/src/routes/passkeys/register-options.ts +10 -10
  278. package/src/routes/passkeys/register-verify.ts +10 -10
  279. package/src/routes/session/index.ts +7 -7
  280. package/src/routes/session/refresh-viability.ts +17 -17
  281. package/src/server/auth-guard.ts +203 -203
  282. package/src/server/auth.ts +486 -486
  283. package/src/server/decode-session.ts +309 -309
  284. package/src/server/slim-middleware.ts +117 -117
  285. package/src/server/verified-session-token.ts +103 -103
  286. package/src/server/with-auth.ts +93 -93
  287. package/src/services/signalrActivityService.ts +258 -258
  288. package/src/stores/authStore.ts +1900 -1900
  289. package/src/templates/instrumentation.ts +60 -60
  290. package/src/theme/ThemeProvider.tsx +39 -39
  291. package/src/theme/default.ts +33 -33
  292. package/src/theme/index.ts +31 -31
  293. package/src/theme/types.ts +69 -69
  294. package/src/theme/useTheme.ts +57 -57
  295. package/src/theme/utils.ts +40 -40
  296. package/src/types/api.ts +12 -12
  297. package/src/types/auth.d.ts +23 -23
  298. package/src/types/auth.ts +48 -48
  299. package/src/types/logging.ts +11 -11
  300. package/src/types/recovery.ts +54 -54
  301. package/src/utils/api.ts +401 -401
  302. package/src/utils/circuitBreaker.ts +40 -40
  303. package/src/utils/error-message.ts +108 -108
  304. package/src/utils/layout/reservedSpace.ts +124 -124
  305. package/src/utils/logout.ts +30 -30
  306. package/src/vibe/client.ts +878 -877
  307. package/src/vibe/enterprise-auth.ts +208 -208
  308. package/src/vibe/errors.ts +209 -209
  309. package/src/vibe/filters.ts +52 -0
  310. package/src/vibe/generic.ts +340 -339
  311. package/src/vibe/hooks/index.ts +367 -367
  312. package/src/vibe/index.ts +133 -133
  313. package/src/vibe/sessions.ts +925 -899
  314. package/src/vibe/types.ts +577 -577
@@ -1,728 +1,728 @@
1
- /**
2
- * =============================================================================
3
- * VIBE ADMIN ALERT SETTINGS TAB
4
- * =============================================================================
5
- *
6
- * Admin UI for managing email alert preferences, recipients, and thresholds.
7
- * Supports Smart/Immediate/Hourly/Daily delivery modes.
8
- *
9
- * =============================================================================
10
- */
11
-
12
- 'use client';
13
-
14
- import React, { useState, useEffect, useCallback } from 'react';
15
- import {
16
- Bell,
17
- Mail,
18
- Plus,
19
- X,
20
- Clock,
21
- Zap,
22
- AlertTriangle,
23
- HardDrive,
24
- Bot,
25
- Send,
26
- History,
27
- RefreshCw,
28
- AlertCircle,
29
- Check,
30
- ChevronDown,
31
- Info,
32
- } from 'lucide-react';
33
-
34
- // -----------------------------------------------------------------------------
35
- // TYPES
36
- // -----------------------------------------------------------------------------
37
-
38
- export type DeliveryMode = 'smart' | 'immediate' | 'hourly' | 'daily';
39
-
40
- export interface AlertConfig {
41
- enabled: boolean;
42
- threshold?: number;
43
- threshold_pct?: number;
44
- }
45
-
46
- export interface AlertsConfig {
47
- error_spike: AlertConfig;
48
- storage_warning: AlertConfig;
49
- storage_critical: AlertConfig;
50
- agent_expiring: AlertConfig;
51
- agent_expired: AlertConfig;
52
- }
53
-
54
- export interface RateLimit {
55
- max_per_hour: number;
56
- }
57
-
58
- export interface AlertSettings {
59
- recipients: string[];
60
- digest_mode: DeliveryMode;
61
- alerts: AlertsConfig;
62
- rate_limit: RateLimit;
63
- }
64
-
65
- export interface AlertHistoryItem {
66
- id: number;
67
- type: string;
68
- sent_at: string;
69
- subject: string;
70
- recipients: string[];
71
- }
72
-
73
- export interface AlertSettingsTabProps {
74
- isDark?: boolean;
75
- /** API base path (default: /api/admin/alerts) */
76
- apiBasePath?: string;
77
- /** Callback when settings are saved */
78
- onSave?: (settings: Partial<AlertSettings>) => void;
79
- }
80
-
81
- // -----------------------------------------------------------------------------
82
- // CONSTANTS
83
- // -----------------------------------------------------------------------------
84
-
85
- const DELIVERY_MODES: { value: DeliveryMode; label: string; description: string }[] = [
86
- { value: 'smart', label: 'Smart', description: 'Critical alerts immediate, others batched (Recommended)' },
87
- { value: 'immediate', label: 'Immediate', description: 'All alerts sent as they occur' },
88
- { value: 'hourly', label: 'Hourly', description: 'Batched into hourly digest' },
89
- { value: 'daily', label: 'Daily', description: 'Batched into daily digest (8 AM)' },
90
- ];
91
-
92
- const ALERT_TYPES: {
93
- id: keyof AlertsConfig;
94
- label: string;
95
- description: string;
96
- icon: React.ElementType;
97
- hasThreshold: boolean;
98
- thresholdLabel?: string;
99
- thresholdUnit?: string;
100
- alwaysSent?: boolean;
101
- }[] = [
102
- {
103
- id: 'error_spike',
104
- label: 'Error Spike',
105
- description: 'Alert when errors exceed threshold',
106
- icon: AlertTriangle,
107
- hasThreshold: true,
108
- thresholdLabel: 'Threshold',
109
- thresholdUnit: 'errors/hour',
110
- },
111
- {
112
- id: 'storage_warning',
113
- label: 'Storage Warning',
114
- description: 'Alert when log storage approaches capacity',
115
- icon: HardDrive,
116
- hasThreshold: true,
117
- thresholdLabel: 'Threshold',
118
- thresholdUnit: '% capacity',
119
- },
120
- {
121
- id: 'storage_critical',
122
- label: 'Storage Critical',
123
- description: 'Alert when storage is nearly full',
124
- icon: HardDrive,
125
- hasThreshold: true,
126
- thresholdLabel: 'Threshold',
127
- thresholdUnit: '% capacity',
128
- alwaysSent: true,
129
- },
130
- {
131
- id: 'agent_expiring',
132
- label: 'Agent Access Expiring',
133
- description: 'Alert 24 hours before agent access expires',
134
- icon: Bot,
135
- hasThreshold: false,
136
- },
137
- {
138
- id: 'agent_expired',
139
- label: 'Agent Access Expired',
140
- description: 'Alert when agent access has expired',
141
- icon: Bot,
142
- hasThreshold: false,
143
- alwaysSent: true,
144
- },
145
- ];
146
-
147
- const TEST_ALERT_OPTIONS = [
148
- { value: 'error_spike', label: 'Error Spike Alert' },
149
- { value: 'storage_warning', label: 'Storage Warning Alert' },
150
- { value: 'agent_expiring', label: 'Agent Expiring Alert' },
151
- ];
152
-
153
- const DEFAULT_SETTINGS: AlertSettings = {
154
- recipients: [],
155
- digest_mode: 'smart',
156
- alerts: {
157
- error_spike: { enabled: true, threshold: 10 },
158
- storage_warning: { enabled: true, threshold_pct: 80 },
159
- storage_critical: { enabled: true, threshold_pct: 95 },
160
- agent_expiring: { enabled: true },
161
- agent_expired: { enabled: true },
162
- },
163
- rate_limit: { max_per_hour: 10 },
164
- };
165
-
166
- // -----------------------------------------------------------------------------
167
- // COMPONENT
168
- // -----------------------------------------------------------------------------
169
-
170
- export function AlertSettingsTab({
171
- isDark = true,
172
- apiBasePath = '/api/admin/alerts',
173
- onSave,
174
- }: AlertSettingsTabProps) {
175
- const [settings, setSettings] = useState<AlertSettings | null>(null);
176
- const [loading, setLoading] = useState(true);
177
- const [saving, setSaving] = useState(false);
178
- const [sendingTest, setSendingTest] = useState(false);
179
- const [error, setError] = useState<string | null>(null);
180
- const [successMessage, setSuccessMessage] = useState<string | null>(null);
181
- const [hasChanges, setHasChanges] = useState(false);
182
-
183
- // Edited state
184
- const [editedRecipients, setEditedRecipients] = useState<string[]>([]);
185
- const [editedMode, setEditedMode] = useState<DeliveryMode>('smart');
186
- const [editedAlerts, setEditedAlerts] = useState<AlertsConfig>(DEFAULT_SETTINGS.alerts);
187
- const [editedRateLimit, setEditedRateLimit] = useState<number>(10);
188
-
189
- // New recipient input
190
- const [newRecipient, setNewRecipient] = useState('');
191
- const [recipientError, setRecipientError] = useState<string | null>(null);
192
-
193
- const themeClasses = {
194
- bg: isDark ? 'bg-slate-950' : 'bg-gray-50',
195
- cardBg: isDark ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200 shadow-sm',
196
- textPrimary: isDark ? 'text-white' : 'text-gray-900',
197
- textSecondary: isDark ? 'text-gray-400' : 'text-gray-600',
198
- textMuted: isDark ? 'text-gray-500' : 'text-gray-500',
199
- inputBg: isDark ? 'bg-slate-900 border-slate-600' : 'bg-white border-gray-300',
200
- hoverBg: isDark ? 'hover:bg-slate-700' : 'hover:bg-gray-100',
201
- tagBg: isDark ? 'bg-slate-700' : 'bg-gray-100',
202
- };
203
-
204
- // Fetch settings
205
- const fetchSettings = useCallback(async () => {
206
- setLoading(true);
207
- setError(null);
208
-
209
- try {
210
- const res = await fetch(`${apiBasePath}/settings`);
211
- if (res.ok) {
212
- const data = await res.json();
213
- setSettings(data);
214
- setEditedRecipients(data.recipients || []);
215
- setEditedMode(data.digest_mode || 'smart');
216
- setEditedAlerts(data.alerts || DEFAULT_SETTINGS.alerts);
217
- setEditedRateLimit(data.rate_limit?.max_per_hour || 10);
218
- } else if (res.status === 404) {
219
- // Endpoint not ready - use defaults
220
- setSettings(DEFAULT_SETTINGS);
221
- setEditedRecipients([]);
222
- setEditedMode('smart');
223
- setEditedAlerts(DEFAULT_SETTINGS.alerts);
224
- setEditedRateLimit(10);
225
- } else {
226
- throw new Error('Failed to fetch settings');
227
- }
228
- } catch (err: any) {
229
- setError(err.message);
230
- } finally {
231
- setLoading(false);
232
- }
233
- }, [apiBasePath]);
234
-
235
- useEffect(() => {
236
- fetchSettings();
237
- }, [fetchSettings]);
238
-
239
- // Check for changes
240
- useEffect(() => {
241
- if (!settings) {
242
- setHasChanges(false);
243
- return;
244
- }
245
-
246
- const recipientsChanged = JSON.stringify(settings.recipients) !== JSON.stringify(editedRecipients);
247
- const modeChanged = settings.digest_mode !== editedMode;
248
- const alertsChanged = JSON.stringify(settings.alerts) !== JSON.stringify(editedAlerts);
249
- const rateLimitChanged = settings.rate_limit.max_per_hour !== editedRateLimit;
250
-
251
- setHasChanges(recipientsChanged || modeChanged || alertsChanged || rateLimitChanged);
252
- }, [settings, editedRecipients, editedMode, editedAlerts, editedRateLimit]);
253
-
254
- // Add recipient
255
- const addRecipient = () => {
256
- const email = newRecipient.trim().toLowerCase();
257
- setRecipientError(null);
258
-
259
- if (!email) return;
260
-
261
- // Basic email validation
262
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
263
- if (!emailRegex.test(email)) {
264
- setRecipientError('Please enter a valid email address');
265
- return;
266
- }
267
-
268
- if (editedRecipients.includes(email)) {
269
- setRecipientError('This email is already in the list');
270
- return;
271
- }
272
-
273
- setEditedRecipients([...editedRecipients, email]);
274
- setNewRecipient('');
275
- };
276
-
277
- // Remove recipient
278
- const removeRecipient = (email: string) => {
279
- setEditedRecipients(editedRecipients.filter(r => r !== email));
280
- };
281
-
282
- // Update alert config
283
- const updateAlertConfig = (alertId: keyof AlertsConfig, updates: Partial<AlertConfig>) => {
284
- setEditedAlerts(prev => ({
285
- ...prev,
286
- [alertId]: { ...prev[alertId], ...updates },
287
- }));
288
- };
289
-
290
- // Save settings
291
- const saveSettings = async () => {
292
- if (!hasChanges) return;
293
-
294
- setSaving(true);
295
- setError(null);
296
-
297
- try {
298
- const payload: AlertSettings = {
299
- recipients: editedRecipients,
300
- digest_mode: editedMode,
301
- alerts: editedAlerts,
302
- rate_limit: { max_per_hour: editedRateLimit },
303
- };
304
-
305
- const res = await fetch(`${apiBasePath}/settings`, {
306
- method: 'PUT',
307
- headers: { 'Content-Type': 'application/json' },
308
- body: JSON.stringify(payload),
309
- });
310
-
311
- if (res.ok) {
312
- setSuccessMessage('Settings saved successfully');
313
- setTimeout(() => setSuccessMessage(null), 3000);
314
- onSave?.(payload);
315
- fetchSettings();
316
- } else if (res.status === 404) {
317
- // Endpoint not ready - simulate success
318
- setSettings(payload);
319
- setSuccessMessage('Settings saved (demo mode)');
320
- setTimeout(() => setSuccessMessage(null), 3000);
321
- } else {
322
- throw new Error('Failed to save settings');
323
- }
324
- } catch (err: any) {
325
- setError(err.message);
326
- } finally {
327
- setSaving(false);
328
- }
329
- };
330
-
331
- // Send test alert
332
- const sendTestAlert = async (alertType: string) => {
333
- setSendingTest(true);
334
- setError(null);
335
-
336
- try {
337
- const res = await fetch(`${apiBasePath}/test`, {
338
- method: 'POST',
339
- headers: { 'Content-Type': 'application/json' },
340
- body: JSON.stringify({
341
- type: alertType,
342
- recipient: editedRecipients[0] || 'test@example.com',
343
- }),
344
- });
345
-
346
- if (res.ok) {
347
- setSuccessMessage(`Test ${alertType.replace('_', ' ')} alert sent!`);
348
- setTimeout(() => setSuccessMessage(null), 3000);
349
- } else if (res.status === 404) {
350
- setSuccessMessage('Test alert sent (demo mode)');
351
- setTimeout(() => setSuccessMessage(null), 3000);
352
- } else {
353
- throw new Error('Failed to send test alert');
354
- }
355
- } catch (err: any) {
356
- setError(err.message);
357
- } finally {
358
- setSendingTest(false);
359
- }
360
- };
361
-
362
- if (loading) {
363
- return (
364
- <div className="flex items-center justify-center py-12">
365
- <RefreshCw className="w-8 h-8 animate-spin text-indigo-500" />
366
- </div>
367
- );
368
- }
369
-
370
- return (
371
- <div className="space-y-6">
372
- {/* Error/Success Messages */}
373
- {error && (
374
- <div className={`flex items-center gap-3 p-4 rounded-lg ${isDark ? 'bg-red-900/30 border-red-700' : 'bg-red-50 border-red-300'} border`}>
375
- <AlertCircle className={isDark ? 'text-red-400' : 'text-red-500'} size={20} />
376
- <span className={isDark ? 'text-red-300' : 'text-red-700'}>{error}</span>
377
- </div>
378
- )}
379
-
380
- {successMessage && (
381
- <div className={`flex items-center gap-3 p-4 rounded-lg ${isDark ? 'bg-green-900/30 border-green-700' : 'bg-green-50 border-green-300'} border`}>
382
- <Check className={isDark ? 'text-green-400' : 'text-green-500'} size={20} />
383
- <span className={isDark ? 'text-green-300' : 'text-green-700'}>{successMessage}</span>
384
- </div>
385
- )}
386
-
387
- {/* Section 1: Recipients */}
388
- <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
389
- <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
390
- <div className="flex items-center gap-2">
391
- <Mail className={isDark ? 'text-violet-400' : 'text-violet-500'} size={20} />
392
- <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Alert Recipients</h3>
393
- </div>
394
- <p className={`text-sm ${themeClasses.textMuted} mt-1`}>
395
- Email addresses that receive alert notifications
396
- </p>
397
- </div>
398
- <div className="p-5 space-y-4">
399
- {/* Recipient list */}
400
- <div className="flex flex-wrap gap-2">
401
- {editedRecipients.map((email) => (
402
- <div
403
- key={email}
404
- className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${themeClasses.tagBg}`}
405
- >
406
- <span className={`text-sm ${themeClasses.textPrimary}`}>{email}</span>
407
- <button
408
- onClick={() => removeRecipient(email)}
409
- className={`p-0.5 rounded ${isDark ? 'hover:bg-slate-600' : 'hover:bg-gray-300'} transition-colors`}
410
- >
411
- <X size={14} className={themeClasses.textMuted} />
412
- </button>
413
- </div>
414
- ))}
415
- {editedRecipients.length === 0 && (
416
- <span className={`text-sm ${themeClasses.textMuted}`}>No recipients configured</span>
417
- )}
418
- </div>
419
-
420
- {/* Add recipient input */}
421
- <div className="flex gap-2">
422
- <div className="flex-1">
423
- <input
424
- type="email"
425
- value={newRecipient}
426
- onChange={(e) => {
427
- setNewRecipient(e.target.value);
428
- setRecipientError(null);
429
- }}
430
- onKeyDown={(e) => {
431
- if (e.key === 'Enter') {
432
- e.preventDefault();
433
- addRecipient();
434
- }
435
- }}
436
- placeholder="Enter email address"
437
- className={`
438
- w-full px-3 py-2 rounded-lg text-sm
439
- ${themeClasses.inputBg} ${themeClasses.textPrimary} border
440
- focus:outline-none focus:ring-2 focus:ring-indigo-500
441
- ${recipientError ? (isDark ? 'border-red-500' : 'border-red-400') : ''}
442
- `}
443
- />
444
- {recipientError && (
445
- <p className={`text-xs mt-1 ${isDark ? 'text-red-400' : 'text-red-500'}`}>
446
- {recipientError}
447
- </p>
448
- )}
449
- </div>
450
- <button
451
- onClick={addRecipient}
452
- className={`
453
- px-4 py-2 rounded-lg font-medium text-sm transition-colors
454
- ${isDark ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-indigo-500 hover:bg-indigo-600'}
455
- text-white flex items-center gap-2
456
- `}
457
- >
458
- <Plus size={16} />
459
- Add
460
- </button>
461
- </div>
462
- </div>
463
- </div>
464
-
465
- {/* Section 2: Delivery Mode */}
466
- <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
467
- <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
468
- <div className="flex items-center gap-2">
469
- <Clock className={isDark ? 'text-cyan-400' : 'text-cyan-500'} size={20} />
470
- <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Delivery Mode</h3>
471
- </div>
472
- <p className={`text-sm ${themeClasses.textMuted} mt-1`}>
473
- How alerts are delivered to recipients
474
- </p>
475
- </div>
476
- <div className="p-5">
477
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
478
- {DELIVERY_MODES.map((mode) => {
479
- const isSelected = editedMode === mode.value;
480
- return (
481
- <button
482
- key={mode.value}
483
- onClick={() => setEditedMode(mode.value)}
484
- className={`
485
- p-4 rounded-lg border-2 text-left transition-all
486
- ${isSelected
487
- ? 'border-indigo-500 ' + (isDark ? 'bg-indigo-500/10' : 'bg-indigo-50')
488
- : (isDark ? 'border-slate-700 hover:border-slate-600' : 'border-gray-200 hover:border-gray-300')
489
- }
490
- `}
491
- >
492
- <div className="flex items-center gap-2 mb-1">
493
- <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
494
- isSelected ? 'border-indigo-500' : (isDark ? 'border-slate-500' : 'border-gray-400')
495
- }`}>
496
- {isSelected && <div className="w-1.5 h-1.5 rounded-full bg-indigo-500" />}
497
- </div>
498
- <span className={`font-medium ${themeClasses.textPrimary}`}>{mode.label}</span>
499
- </div>
500
- <p className={`text-xs ${themeClasses.textMuted} ml-5`}>{mode.description}</p>
501
- </button>
502
- );
503
- })}
504
- </div>
505
- </div>
506
- </div>
507
-
508
- {/* Section 3: Alert Configuration */}
509
- <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
510
- <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
511
- <div className="flex items-center gap-2">
512
- <Bell className={isDark ? 'text-amber-400' : 'text-amber-500'} size={20} />
513
- <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Alert Types</h3>
514
- </div>
515
- <p className={`text-sm ${themeClasses.textMuted} mt-1`}>
516
- Configure which alerts to receive and their thresholds
517
- </p>
518
- </div>
519
- <div className="divide-y divide-slate-700/50">
520
- {ALERT_TYPES.map((alertType) => {
521
- const config = editedAlerts[alertType.id];
522
- const Icon = alertType.icon;
523
-
524
- return (
525
- <div key={alertType.id} className="p-5">
526
- <div className="flex items-start justify-between gap-4">
527
- <div className="flex items-start gap-3">
528
- <div className={`p-2 rounded-lg ${isDark ? 'bg-slate-700' : 'bg-gray-100'}`}>
529
- <Icon size={18} className={themeClasses.textSecondary} />
530
- </div>
531
- <div>
532
- <div className="flex items-center gap-2">
533
- <span className={`font-medium ${themeClasses.textPrimary}`}>{alertType.label}</span>
534
- {alertType.alwaysSent && (
535
- <span className={`px-2 py-0.5 text-xs rounded ${isDark ? 'bg-red-500/20 text-red-400' : 'bg-red-100 text-red-600'}`}>
536
- Always sent
537
- </span>
538
- )}
539
- </div>
540
- <p className={`text-sm ${themeClasses.textMuted}`}>{alertType.description}</p>
541
- </div>
542
- </div>
543
-
544
- <div className="flex items-center gap-4">
545
- {/* Threshold input */}
546
- {alertType.hasThreshold && (
547
- <div className="flex items-center gap-2">
548
- <input
549
- type="number"
550
- min="1"
551
- max="100"
552
- value={config.threshold ?? config.threshold_pct ?? 10}
553
- onChange={(e) => {
554
- const val = parseInt(e.target.value) || 10;
555
- if (alertType.thresholdUnit?.includes('%')) {
556
- updateAlertConfig(alertType.id, { threshold_pct: val });
557
- } else {
558
- updateAlertConfig(alertType.id, { threshold: val });
559
- }
560
- }}
561
- disabled={!config.enabled && !alertType.alwaysSent}
562
- className={`
563
- w-16 px-2 py-1.5 rounded text-sm text-center
564
- ${themeClasses.inputBg} ${themeClasses.textPrimary} border
565
- focus:outline-none focus:ring-2 focus:ring-indigo-500
566
- disabled:opacity-50
567
- `}
568
- />
569
- <span className={`text-xs ${themeClasses.textMuted} whitespace-nowrap`}>
570
- {alertType.thresholdUnit}
571
- </span>
572
- </div>
573
- )}
574
-
575
- {/* Toggle */}
576
- {!alertType.alwaysSent && (
577
- <button
578
- onClick={() => updateAlertConfig(alertType.id, { enabled: !config.enabled })}
579
- className={`
580
- relative w-11 h-6 rounded-full transition-colors
581
- ${config.enabled
582
- ? (isDark ? 'bg-indigo-600' : 'bg-indigo-500')
583
- : (isDark ? 'bg-slate-600' : 'bg-gray-300')
584
- }
585
- `}
586
- >
587
- <span
588
- className={`
589
- absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform
590
- ${config.enabled ? 'translate-x-5' : ''}
591
- `}
592
- />
593
- </button>
594
- )}
595
- </div>
596
- </div>
597
- </div>
598
- );
599
- })}
600
- </div>
601
- </div>
602
-
603
- {/* Section 4: Rate Limiting */}
604
- <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
605
- <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
606
- <div className="flex items-center gap-2">
607
- <Zap className={isDark ? 'text-emerald-400' : 'text-emerald-500'} size={20} />
608
- <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Rate Limiting</h3>
609
- </div>
610
- </div>
611
- <div className="p-5">
612
- <div className="flex items-center gap-4">
613
- <label className={`text-sm ${themeClasses.textSecondary}`}>Maximum alerts:</label>
614
- <input
615
- type="number"
616
- min="1"
617
- max="100"
618
- value={editedRateLimit}
619
- onChange={(e) => setEditedRateLimit(parseInt(e.target.value) || 10)}
620
- className={`
621
- w-20 px-3 py-2 rounded-lg text-sm
622
- ${themeClasses.inputBg} ${themeClasses.textPrimary} border
623
- focus:outline-none focus:ring-2 focus:ring-indigo-500
624
- `}
625
- />
626
- <span className={`text-sm ${themeClasses.textMuted}`}>per hour</span>
627
- </div>
628
- <div className={`flex items-start gap-2 mt-3 text-xs ${themeClasses.textMuted}`}>
629
- <Info size={14} className="flex-shrink-0 mt-0.5" />
630
- <span>Critical alerts (Storage Critical, Agent Expired) are always sent regardless of this limit.</span>
631
- </div>
632
- </div>
633
- </div>
634
-
635
- {/* Section 5: Test & History */}
636
- <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
637
- <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
638
- <div className="flex items-center gap-2">
639
- <Send className={isDark ? 'text-blue-400' : 'text-blue-500'} size={20} />
640
- <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Test & History</h3>
641
- </div>
642
- </div>
643
- <div className="p-5">
644
- <div className="flex flex-wrap gap-3">
645
- {/* Send Test Alert dropdown */}
646
- <div className="relative">
647
- <select
648
- disabled={sendingTest || editedRecipients.length === 0}
649
- onChange={(e) => {
650
- if (e.target.value) {
651
- sendTestAlert(e.target.value);
652
- e.target.value = '';
653
- }
654
- }}
655
- className={`
656
- px-4 py-2.5 pr-10 rounded-lg text-sm font-medium cursor-pointer appearance-none
657
- ${isDark ? 'bg-blue-900/30 text-blue-300 border-blue-700 hover:bg-blue-900/50' : 'bg-blue-50 text-blue-700 border-blue-300 hover:bg-blue-100'}
658
- border focus:outline-none focus:ring-2 focus:ring-blue-500
659
- disabled:opacity-50 disabled:cursor-not-allowed
660
- `}
661
- >
662
- <option value="">Send Test Alert...</option>
663
- {TEST_ALERT_OPTIONS.map((opt) => (
664
- <option key={opt.value} value={opt.value}>{opt.label}</option>
665
- ))}
666
- </select>
667
- <ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
668
- </div>
669
-
670
- {/* View History button */}
671
- <button
672
- onClick={() => {
673
- // TODO: Open history modal or navigate to history page
674
- window.open(`${apiBasePath}/history`, '_blank');
675
- }}
676
- className={`
677
- px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2
678
- ${isDark ? 'bg-slate-700 text-white hover:bg-slate-600' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}
679
- border ${isDark ? 'border-slate-600' : 'border-gray-300'}
680
- `}
681
- >
682
- <History size={16} />
683
- View Alert History
684
- </button>
685
- </div>
686
-
687
- {editedRecipients.length === 0 && (
688
- <p className={`text-xs mt-3 ${themeClasses.textMuted}`}>
689
- Add at least one recipient to send test alerts.
690
- </p>
691
- )}
692
- </div>
693
- </div>
694
-
695
- {/* Save Button */}
696
- <div className="flex items-center justify-end gap-4">
697
- {hasChanges && (
698
- <span className={`text-sm ${themeClasses.textMuted}`}>
699
- You have unsaved changes
700
- </span>
701
- )}
702
- <button
703
- onClick={saveSettings}
704
- disabled={!hasChanges || saving}
705
- className={`
706
- px-6 py-2.5 rounded-lg font-medium text-sm transition-all
707
- ${hasChanges
708
- ? 'bg-indigo-600 text-white hover:bg-indigo-700'
709
- : (isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-400')
710
- }
711
- disabled:cursor-not-allowed
712
- `}
713
- >
714
- {saving ? (
715
- <span className="flex items-center gap-2">
716
- <RefreshCw className="w-4 h-4 animate-spin" />
717
- Saving...
718
- </span>
719
- ) : (
720
- 'Save Settings'
721
- )}
722
- </button>
723
- </div>
724
- </div>
725
- );
726
- }
727
-
728
- export default AlertSettingsTab;
1
+ /**
2
+ * =============================================================================
3
+ * VIBE ADMIN ALERT SETTINGS TAB
4
+ * =============================================================================
5
+ *
6
+ * Admin UI for managing email alert preferences, recipients, and thresholds.
7
+ * Supports Smart/Immediate/Hourly/Daily delivery modes.
8
+ *
9
+ * =============================================================================
10
+ */
11
+
12
+ 'use client';
13
+
14
+ import React, { useState, useEffect, useCallback } from 'react';
15
+ import {
16
+ Bell,
17
+ Mail,
18
+ Plus,
19
+ X,
20
+ Clock,
21
+ Zap,
22
+ AlertTriangle,
23
+ HardDrive,
24
+ Bot,
25
+ Send,
26
+ History,
27
+ RefreshCw,
28
+ AlertCircle,
29
+ Check,
30
+ ChevronDown,
31
+ Info,
32
+ } from 'lucide-react';
33
+
34
+ // -----------------------------------------------------------------------------
35
+ // TYPES
36
+ // -----------------------------------------------------------------------------
37
+
38
+ export type DeliveryMode = 'smart' | 'immediate' | 'hourly' | 'daily';
39
+
40
+ export interface AlertConfig {
41
+ enabled: boolean;
42
+ threshold?: number;
43
+ threshold_pct?: number;
44
+ }
45
+
46
+ export interface AlertsConfig {
47
+ error_spike: AlertConfig;
48
+ storage_warning: AlertConfig;
49
+ storage_critical: AlertConfig;
50
+ agent_expiring: AlertConfig;
51
+ agent_expired: AlertConfig;
52
+ }
53
+
54
+ export interface RateLimit {
55
+ max_per_hour: number;
56
+ }
57
+
58
+ export interface AlertSettings {
59
+ recipients: string[];
60
+ digest_mode: DeliveryMode;
61
+ alerts: AlertsConfig;
62
+ rate_limit: RateLimit;
63
+ }
64
+
65
+ export interface AlertHistoryItem {
66
+ id: number;
67
+ type: string;
68
+ sent_at: string;
69
+ subject: string;
70
+ recipients: string[];
71
+ }
72
+
73
+ export interface AlertSettingsTabProps {
74
+ isDark?: boolean;
75
+ /** API base path (default: /api/admin/alerts) */
76
+ apiBasePath?: string;
77
+ /** Callback when settings are saved */
78
+ onSave?: (settings: Partial<AlertSettings>) => void;
79
+ }
80
+
81
+ // -----------------------------------------------------------------------------
82
+ // CONSTANTS
83
+ // -----------------------------------------------------------------------------
84
+
85
+ const DELIVERY_MODES: { value: DeliveryMode; label: string; description: string }[] = [
86
+ { value: 'smart', label: 'Smart', description: 'Critical alerts immediate, others batched (Recommended)' },
87
+ { value: 'immediate', label: 'Immediate', description: 'All alerts sent as they occur' },
88
+ { value: 'hourly', label: 'Hourly', description: 'Batched into hourly digest' },
89
+ { value: 'daily', label: 'Daily', description: 'Batched into daily digest (8 AM)' },
90
+ ];
91
+
92
+ const ALERT_TYPES: {
93
+ id: keyof AlertsConfig;
94
+ label: string;
95
+ description: string;
96
+ icon: React.ElementType;
97
+ hasThreshold: boolean;
98
+ thresholdLabel?: string;
99
+ thresholdUnit?: string;
100
+ alwaysSent?: boolean;
101
+ }[] = [
102
+ {
103
+ id: 'error_spike',
104
+ label: 'Error Spike',
105
+ description: 'Alert when errors exceed threshold',
106
+ icon: AlertTriangle,
107
+ hasThreshold: true,
108
+ thresholdLabel: 'Threshold',
109
+ thresholdUnit: 'errors/hour',
110
+ },
111
+ {
112
+ id: 'storage_warning',
113
+ label: 'Storage Warning',
114
+ description: 'Alert when log storage approaches capacity',
115
+ icon: HardDrive,
116
+ hasThreshold: true,
117
+ thresholdLabel: 'Threshold',
118
+ thresholdUnit: '% capacity',
119
+ },
120
+ {
121
+ id: 'storage_critical',
122
+ label: 'Storage Critical',
123
+ description: 'Alert when storage is nearly full',
124
+ icon: HardDrive,
125
+ hasThreshold: true,
126
+ thresholdLabel: 'Threshold',
127
+ thresholdUnit: '% capacity',
128
+ alwaysSent: true,
129
+ },
130
+ {
131
+ id: 'agent_expiring',
132
+ label: 'Agent Access Expiring',
133
+ description: 'Alert 24 hours before agent access expires',
134
+ icon: Bot,
135
+ hasThreshold: false,
136
+ },
137
+ {
138
+ id: 'agent_expired',
139
+ label: 'Agent Access Expired',
140
+ description: 'Alert when agent access has expired',
141
+ icon: Bot,
142
+ hasThreshold: false,
143
+ alwaysSent: true,
144
+ },
145
+ ];
146
+
147
+ const TEST_ALERT_OPTIONS = [
148
+ { value: 'error_spike', label: 'Error Spike Alert' },
149
+ { value: 'storage_warning', label: 'Storage Warning Alert' },
150
+ { value: 'agent_expiring', label: 'Agent Expiring Alert' },
151
+ ];
152
+
153
+ const DEFAULT_SETTINGS: AlertSettings = {
154
+ recipients: [],
155
+ digest_mode: 'smart',
156
+ alerts: {
157
+ error_spike: { enabled: true, threshold: 10 },
158
+ storage_warning: { enabled: true, threshold_pct: 80 },
159
+ storage_critical: { enabled: true, threshold_pct: 95 },
160
+ agent_expiring: { enabled: true },
161
+ agent_expired: { enabled: true },
162
+ },
163
+ rate_limit: { max_per_hour: 10 },
164
+ };
165
+
166
+ // -----------------------------------------------------------------------------
167
+ // COMPONENT
168
+ // -----------------------------------------------------------------------------
169
+
170
+ export function AlertSettingsTab({
171
+ isDark = true,
172
+ apiBasePath = '/api/admin/alerts',
173
+ onSave,
174
+ }: AlertSettingsTabProps) {
175
+ const [settings, setSettings] = useState<AlertSettings | null>(null);
176
+ const [loading, setLoading] = useState(true);
177
+ const [saving, setSaving] = useState(false);
178
+ const [sendingTest, setSendingTest] = useState(false);
179
+ const [error, setError] = useState<string | null>(null);
180
+ const [successMessage, setSuccessMessage] = useState<string | null>(null);
181
+ const [hasChanges, setHasChanges] = useState(false);
182
+
183
+ // Edited state
184
+ const [editedRecipients, setEditedRecipients] = useState<string[]>([]);
185
+ const [editedMode, setEditedMode] = useState<DeliveryMode>('smart');
186
+ const [editedAlerts, setEditedAlerts] = useState<AlertsConfig>(DEFAULT_SETTINGS.alerts);
187
+ const [editedRateLimit, setEditedRateLimit] = useState<number>(10);
188
+
189
+ // New recipient input
190
+ const [newRecipient, setNewRecipient] = useState('');
191
+ const [recipientError, setRecipientError] = useState<string | null>(null);
192
+
193
+ const themeClasses = {
194
+ bg: isDark ? 'bg-slate-950' : 'bg-gray-50',
195
+ cardBg: isDark ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200 shadow-sm',
196
+ textPrimary: isDark ? 'text-white' : 'text-gray-900',
197
+ textSecondary: isDark ? 'text-gray-400' : 'text-gray-600',
198
+ textMuted: isDark ? 'text-gray-500' : 'text-gray-500',
199
+ inputBg: isDark ? 'bg-slate-900 border-slate-600' : 'bg-white border-gray-300',
200
+ hoverBg: isDark ? 'hover:bg-slate-700' : 'hover:bg-gray-100',
201
+ tagBg: isDark ? 'bg-slate-700' : 'bg-gray-100',
202
+ };
203
+
204
+ // Fetch settings
205
+ const fetchSettings = useCallback(async () => {
206
+ setLoading(true);
207
+ setError(null);
208
+
209
+ try {
210
+ const res = await fetch(`${apiBasePath}/settings`);
211
+ if (res.ok) {
212
+ const data = await res.json();
213
+ setSettings(data);
214
+ setEditedRecipients(data.recipients || []);
215
+ setEditedMode(data.digest_mode || 'smart');
216
+ setEditedAlerts(data.alerts || DEFAULT_SETTINGS.alerts);
217
+ setEditedRateLimit(data.rate_limit?.max_per_hour || 10);
218
+ } else if (res.status === 404) {
219
+ // Endpoint not ready - use defaults
220
+ setSettings(DEFAULT_SETTINGS);
221
+ setEditedRecipients([]);
222
+ setEditedMode('smart');
223
+ setEditedAlerts(DEFAULT_SETTINGS.alerts);
224
+ setEditedRateLimit(10);
225
+ } else {
226
+ throw new Error('Failed to fetch settings');
227
+ }
228
+ } catch (err: any) {
229
+ setError(err.message);
230
+ } finally {
231
+ setLoading(false);
232
+ }
233
+ }, [apiBasePath]);
234
+
235
+ useEffect(() => {
236
+ fetchSettings();
237
+ }, [fetchSettings]);
238
+
239
+ // Check for changes
240
+ useEffect(() => {
241
+ if (!settings) {
242
+ setHasChanges(false);
243
+ return;
244
+ }
245
+
246
+ const recipientsChanged = JSON.stringify(settings.recipients) !== JSON.stringify(editedRecipients);
247
+ const modeChanged = settings.digest_mode !== editedMode;
248
+ const alertsChanged = JSON.stringify(settings.alerts) !== JSON.stringify(editedAlerts);
249
+ const rateLimitChanged = settings.rate_limit.max_per_hour !== editedRateLimit;
250
+
251
+ setHasChanges(recipientsChanged || modeChanged || alertsChanged || rateLimitChanged);
252
+ }, [settings, editedRecipients, editedMode, editedAlerts, editedRateLimit]);
253
+
254
+ // Add recipient
255
+ const addRecipient = () => {
256
+ const email = newRecipient.trim().toLowerCase();
257
+ setRecipientError(null);
258
+
259
+ if (!email) return;
260
+
261
+ // Basic email validation
262
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
263
+ if (!emailRegex.test(email)) {
264
+ setRecipientError('Please enter a valid email address');
265
+ return;
266
+ }
267
+
268
+ if (editedRecipients.includes(email)) {
269
+ setRecipientError('This email is already in the list');
270
+ return;
271
+ }
272
+
273
+ setEditedRecipients([...editedRecipients, email]);
274
+ setNewRecipient('');
275
+ };
276
+
277
+ // Remove recipient
278
+ const removeRecipient = (email: string) => {
279
+ setEditedRecipients(editedRecipients.filter(r => r !== email));
280
+ };
281
+
282
+ // Update alert config
283
+ const updateAlertConfig = (alertId: keyof AlertsConfig, updates: Partial<AlertConfig>) => {
284
+ setEditedAlerts(prev => ({
285
+ ...prev,
286
+ [alertId]: { ...prev[alertId], ...updates },
287
+ }));
288
+ };
289
+
290
+ // Save settings
291
+ const saveSettings = async () => {
292
+ if (!hasChanges) return;
293
+
294
+ setSaving(true);
295
+ setError(null);
296
+
297
+ try {
298
+ const payload: AlertSettings = {
299
+ recipients: editedRecipients,
300
+ digest_mode: editedMode,
301
+ alerts: editedAlerts,
302
+ rate_limit: { max_per_hour: editedRateLimit },
303
+ };
304
+
305
+ const res = await fetch(`${apiBasePath}/settings`, {
306
+ method: 'PUT',
307
+ headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify(payload),
309
+ });
310
+
311
+ if (res.ok) {
312
+ setSuccessMessage('Settings saved successfully');
313
+ setTimeout(() => setSuccessMessage(null), 3000);
314
+ onSave?.(payload);
315
+ fetchSettings();
316
+ } else if (res.status === 404) {
317
+ // Endpoint not ready - simulate success
318
+ setSettings(payload);
319
+ setSuccessMessage('Settings saved (demo mode)');
320
+ setTimeout(() => setSuccessMessage(null), 3000);
321
+ } else {
322
+ throw new Error('Failed to save settings');
323
+ }
324
+ } catch (err: any) {
325
+ setError(err.message);
326
+ } finally {
327
+ setSaving(false);
328
+ }
329
+ };
330
+
331
+ // Send test alert
332
+ const sendTestAlert = async (alertType: string) => {
333
+ setSendingTest(true);
334
+ setError(null);
335
+
336
+ try {
337
+ const res = await fetch(`${apiBasePath}/test`, {
338
+ method: 'POST',
339
+ headers: { 'Content-Type': 'application/json' },
340
+ body: JSON.stringify({
341
+ type: alertType,
342
+ recipient: editedRecipients[0] || 'test@example.com',
343
+ }),
344
+ });
345
+
346
+ if (res.ok) {
347
+ setSuccessMessage(`Test ${alertType.replace('_', ' ')} alert sent!`);
348
+ setTimeout(() => setSuccessMessage(null), 3000);
349
+ } else if (res.status === 404) {
350
+ setSuccessMessage('Test alert sent (demo mode)');
351
+ setTimeout(() => setSuccessMessage(null), 3000);
352
+ } else {
353
+ throw new Error('Failed to send test alert');
354
+ }
355
+ } catch (err: any) {
356
+ setError(err.message);
357
+ } finally {
358
+ setSendingTest(false);
359
+ }
360
+ };
361
+
362
+ if (loading) {
363
+ return (
364
+ <div className="flex items-center justify-center py-12">
365
+ <RefreshCw className="w-8 h-8 animate-spin text-indigo-500" />
366
+ </div>
367
+ );
368
+ }
369
+
370
+ return (
371
+ <div className="space-y-6">
372
+ {/* Error/Success Messages */}
373
+ {error && (
374
+ <div className={`flex items-center gap-3 p-4 rounded-lg ${isDark ? 'bg-red-900/30 border-red-700' : 'bg-red-50 border-red-300'} border`}>
375
+ <AlertCircle className={isDark ? 'text-red-400' : 'text-red-500'} size={20} />
376
+ <span className={isDark ? 'text-red-300' : 'text-red-700'}>{error}</span>
377
+ </div>
378
+ )}
379
+
380
+ {successMessage && (
381
+ <div className={`flex items-center gap-3 p-4 rounded-lg ${isDark ? 'bg-green-900/30 border-green-700' : 'bg-green-50 border-green-300'} border`}>
382
+ <Check className={isDark ? 'text-green-400' : 'text-green-500'} size={20} />
383
+ <span className={isDark ? 'text-green-300' : 'text-green-700'}>{successMessage}</span>
384
+ </div>
385
+ )}
386
+
387
+ {/* Section 1: Recipients */}
388
+ <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
389
+ <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
390
+ <div className="flex items-center gap-2">
391
+ <Mail className={isDark ? 'text-violet-400' : 'text-violet-500'} size={20} />
392
+ <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Alert Recipients</h3>
393
+ </div>
394
+ <p className={`text-sm ${themeClasses.textMuted} mt-1`}>
395
+ Email addresses that receive alert notifications
396
+ </p>
397
+ </div>
398
+ <div className="p-5 space-y-4">
399
+ {/* Recipient list */}
400
+ <div className="flex flex-wrap gap-2">
401
+ {editedRecipients.map((email) => (
402
+ <div
403
+ key={email}
404
+ className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${themeClasses.tagBg}`}
405
+ >
406
+ <span className={`text-sm ${themeClasses.textPrimary}`}>{email}</span>
407
+ <button
408
+ onClick={() => removeRecipient(email)}
409
+ className={`p-0.5 rounded ${isDark ? 'hover:bg-slate-600' : 'hover:bg-gray-300'} transition-colors`}
410
+ >
411
+ <X size={14} className={themeClasses.textMuted} />
412
+ </button>
413
+ </div>
414
+ ))}
415
+ {editedRecipients.length === 0 && (
416
+ <span className={`text-sm ${themeClasses.textMuted}`}>No recipients configured</span>
417
+ )}
418
+ </div>
419
+
420
+ {/* Add recipient input */}
421
+ <div className="flex gap-2">
422
+ <div className="flex-1">
423
+ <input
424
+ type="email"
425
+ value={newRecipient}
426
+ onChange={(e) => {
427
+ setNewRecipient(e.target.value);
428
+ setRecipientError(null);
429
+ }}
430
+ onKeyDown={(e) => {
431
+ if (e.key === 'Enter') {
432
+ e.preventDefault();
433
+ addRecipient();
434
+ }
435
+ }}
436
+ placeholder="Enter email address"
437
+ className={`
438
+ w-full px-3 py-2 rounded-lg text-sm
439
+ ${themeClasses.inputBg} ${themeClasses.textPrimary} border
440
+ focus:outline-none focus:ring-2 focus:ring-indigo-500
441
+ ${recipientError ? (isDark ? 'border-red-500' : 'border-red-400') : ''}
442
+ `}
443
+ />
444
+ {recipientError && (
445
+ <p className={`text-xs mt-1 ${isDark ? 'text-red-400' : 'text-red-500'}`}>
446
+ {recipientError}
447
+ </p>
448
+ )}
449
+ </div>
450
+ <button
451
+ onClick={addRecipient}
452
+ className={`
453
+ px-4 py-2 rounded-lg font-medium text-sm transition-colors
454
+ ${isDark ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-indigo-500 hover:bg-indigo-600'}
455
+ text-white flex items-center gap-2
456
+ `}
457
+ >
458
+ <Plus size={16} />
459
+ Add
460
+ </button>
461
+ </div>
462
+ </div>
463
+ </div>
464
+
465
+ {/* Section 2: Delivery Mode */}
466
+ <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
467
+ <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
468
+ <div className="flex items-center gap-2">
469
+ <Clock className={isDark ? 'text-cyan-400' : 'text-cyan-500'} size={20} />
470
+ <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Delivery Mode</h3>
471
+ </div>
472
+ <p className={`text-sm ${themeClasses.textMuted} mt-1`}>
473
+ How alerts are delivered to recipients
474
+ </p>
475
+ </div>
476
+ <div className="p-5">
477
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
478
+ {DELIVERY_MODES.map((mode) => {
479
+ const isSelected = editedMode === mode.value;
480
+ return (
481
+ <button
482
+ key={mode.value}
483
+ onClick={() => setEditedMode(mode.value)}
484
+ className={`
485
+ p-4 rounded-lg border-2 text-left transition-all
486
+ ${isSelected
487
+ ? 'border-indigo-500 ' + (isDark ? 'bg-indigo-500/10' : 'bg-indigo-50')
488
+ : (isDark ? 'border-slate-700 hover:border-slate-600' : 'border-gray-200 hover:border-gray-300')
489
+ }
490
+ `}
491
+ >
492
+ <div className="flex items-center gap-2 mb-1">
493
+ <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
494
+ isSelected ? 'border-indigo-500' : (isDark ? 'border-slate-500' : 'border-gray-400')
495
+ }`}>
496
+ {isSelected && <div className="w-1.5 h-1.5 rounded-full bg-indigo-500" />}
497
+ </div>
498
+ <span className={`font-medium ${themeClasses.textPrimary}`}>{mode.label}</span>
499
+ </div>
500
+ <p className={`text-xs ${themeClasses.textMuted} ml-5`}>{mode.description}</p>
501
+ </button>
502
+ );
503
+ })}
504
+ </div>
505
+ </div>
506
+ </div>
507
+
508
+ {/* Section 3: Alert Configuration */}
509
+ <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
510
+ <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
511
+ <div className="flex items-center gap-2">
512
+ <Bell className={isDark ? 'text-amber-400' : 'text-amber-500'} size={20} />
513
+ <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Alert Types</h3>
514
+ </div>
515
+ <p className={`text-sm ${themeClasses.textMuted} mt-1`}>
516
+ Configure which alerts to receive and their thresholds
517
+ </p>
518
+ </div>
519
+ <div className="divide-y divide-slate-700/50">
520
+ {ALERT_TYPES.map((alertType) => {
521
+ const config = editedAlerts[alertType.id];
522
+ const Icon = alertType.icon;
523
+
524
+ return (
525
+ <div key={alertType.id} className="p-5">
526
+ <div className="flex items-start justify-between gap-4">
527
+ <div className="flex items-start gap-3">
528
+ <div className={`p-2 rounded-lg ${isDark ? 'bg-slate-700' : 'bg-gray-100'}`}>
529
+ <Icon size={18} className={themeClasses.textSecondary} />
530
+ </div>
531
+ <div>
532
+ <div className="flex items-center gap-2">
533
+ <span className={`font-medium ${themeClasses.textPrimary}`}>{alertType.label}</span>
534
+ {alertType.alwaysSent && (
535
+ <span className={`px-2 py-0.5 text-xs rounded ${isDark ? 'bg-red-500/20 text-red-400' : 'bg-red-100 text-red-600'}`}>
536
+ Always sent
537
+ </span>
538
+ )}
539
+ </div>
540
+ <p className={`text-sm ${themeClasses.textMuted}`}>{alertType.description}</p>
541
+ </div>
542
+ </div>
543
+
544
+ <div className="flex items-center gap-4">
545
+ {/* Threshold input */}
546
+ {alertType.hasThreshold && (
547
+ <div className="flex items-center gap-2">
548
+ <input
549
+ type="number"
550
+ min="1"
551
+ max="100"
552
+ value={config.threshold ?? config.threshold_pct ?? 10}
553
+ onChange={(e) => {
554
+ const val = parseInt(e.target.value) || 10;
555
+ if (alertType.thresholdUnit?.includes('%')) {
556
+ updateAlertConfig(alertType.id, { threshold_pct: val });
557
+ } else {
558
+ updateAlertConfig(alertType.id, { threshold: val });
559
+ }
560
+ }}
561
+ disabled={!config.enabled && !alertType.alwaysSent}
562
+ className={`
563
+ w-16 px-2 py-1.5 rounded text-sm text-center
564
+ ${themeClasses.inputBg} ${themeClasses.textPrimary} border
565
+ focus:outline-none focus:ring-2 focus:ring-indigo-500
566
+ disabled:opacity-50
567
+ `}
568
+ />
569
+ <span className={`text-xs ${themeClasses.textMuted} whitespace-nowrap`}>
570
+ {alertType.thresholdUnit}
571
+ </span>
572
+ </div>
573
+ )}
574
+
575
+ {/* Toggle */}
576
+ {!alertType.alwaysSent && (
577
+ <button
578
+ onClick={() => updateAlertConfig(alertType.id, { enabled: !config.enabled })}
579
+ className={`
580
+ relative w-11 h-6 rounded-full transition-colors
581
+ ${config.enabled
582
+ ? (isDark ? 'bg-indigo-600' : 'bg-indigo-500')
583
+ : (isDark ? 'bg-slate-600' : 'bg-gray-300')
584
+ }
585
+ `}
586
+ >
587
+ <span
588
+ className={`
589
+ absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform
590
+ ${config.enabled ? 'translate-x-5' : ''}
591
+ `}
592
+ />
593
+ </button>
594
+ )}
595
+ </div>
596
+ </div>
597
+ </div>
598
+ );
599
+ })}
600
+ </div>
601
+ </div>
602
+
603
+ {/* Section 4: Rate Limiting */}
604
+ <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
605
+ <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
606
+ <div className="flex items-center gap-2">
607
+ <Zap className={isDark ? 'text-emerald-400' : 'text-emerald-500'} size={20} />
608
+ <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Rate Limiting</h3>
609
+ </div>
610
+ </div>
611
+ <div className="p-5">
612
+ <div className="flex items-center gap-4">
613
+ <label className={`text-sm ${themeClasses.textSecondary}`}>Maximum alerts:</label>
614
+ <input
615
+ type="number"
616
+ min="1"
617
+ max="100"
618
+ value={editedRateLimit}
619
+ onChange={(e) => setEditedRateLimit(parseInt(e.target.value) || 10)}
620
+ className={`
621
+ w-20 px-3 py-2 rounded-lg text-sm
622
+ ${themeClasses.inputBg} ${themeClasses.textPrimary} border
623
+ focus:outline-none focus:ring-2 focus:ring-indigo-500
624
+ `}
625
+ />
626
+ <span className={`text-sm ${themeClasses.textMuted}`}>per hour</span>
627
+ </div>
628
+ <div className={`flex items-start gap-2 mt-3 text-xs ${themeClasses.textMuted}`}>
629
+ <Info size={14} className="flex-shrink-0 mt-0.5" />
630
+ <span>Critical alerts (Storage Critical, Agent Expired) are always sent regardless of this limit.</span>
631
+ </div>
632
+ </div>
633
+ </div>
634
+
635
+ {/* Section 5: Test & History */}
636
+ <div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
637
+ <div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
638
+ <div className="flex items-center gap-2">
639
+ <Send className={isDark ? 'text-blue-400' : 'text-blue-500'} size={20} />
640
+ <h3 className={`font-semibold ${themeClasses.textPrimary}`}>Test & History</h3>
641
+ </div>
642
+ </div>
643
+ <div className="p-5">
644
+ <div className="flex flex-wrap gap-3">
645
+ {/* Send Test Alert dropdown */}
646
+ <div className="relative">
647
+ <select
648
+ disabled={sendingTest || editedRecipients.length === 0}
649
+ onChange={(e) => {
650
+ if (e.target.value) {
651
+ sendTestAlert(e.target.value);
652
+ e.target.value = '';
653
+ }
654
+ }}
655
+ className={`
656
+ px-4 py-2.5 pr-10 rounded-lg text-sm font-medium cursor-pointer appearance-none
657
+ ${isDark ? 'bg-blue-900/30 text-blue-300 border-blue-700 hover:bg-blue-900/50' : 'bg-blue-50 text-blue-700 border-blue-300 hover:bg-blue-100'}
658
+ border focus:outline-none focus:ring-2 focus:ring-blue-500
659
+ disabled:opacity-50 disabled:cursor-not-allowed
660
+ `}
661
+ >
662
+ <option value="">Send Test Alert...</option>
663
+ {TEST_ALERT_OPTIONS.map((opt) => (
664
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
665
+ ))}
666
+ </select>
667
+ <ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
668
+ </div>
669
+
670
+ {/* View History button */}
671
+ <button
672
+ onClick={() => {
673
+ // TODO: Open history modal or navigate to history page
674
+ window.open(`${apiBasePath}/history`, '_blank');
675
+ }}
676
+ className={`
677
+ px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2
678
+ ${isDark ? 'bg-slate-700 text-white hover:bg-slate-600' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}
679
+ border ${isDark ? 'border-slate-600' : 'border-gray-300'}
680
+ `}
681
+ >
682
+ <History size={16} />
683
+ View Alert History
684
+ </button>
685
+ </div>
686
+
687
+ {editedRecipients.length === 0 && (
688
+ <p className={`text-xs mt-3 ${themeClasses.textMuted}`}>
689
+ Add at least one recipient to send test alerts.
690
+ </p>
691
+ )}
692
+ </div>
693
+ </div>
694
+
695
+ {/* Save Button */}
696
+ <div className="flex items-center justify-end gap-4">
697
+ {hasChanges && (
698
+ <span className={`text-sm ${themeClasses.textMuted}`}>
699
+ You have unsaved changes
700
+ </span>
701
+ )}
702
+ <button
703
+ onClick={saveSettings}
704
+ disabled={!hasChanges || saving}
705
+ className={`
706
+ px-6 py-2.5 rounded-lg font-medium text-sm transition-all
707
+ ${hasChanges
708
+ ? 'bg-indigo-600 text-white hover:bg-indigo-700'
709
+ : (isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-400')
710
+ }
711
+ disabled:cursor-not-allowed
712
+ `}
713
+ >
714
+ {saving ? (
715
+ <span className="flex items-center gap-2">
716
+ <RefreshCw className="w-4 h-4 animate-spin" />
717
+ Saving...
718
+ </span>
719
+ ) : (
720
+ 'Save Settings'
721
+ )}
722
+ </button>
723
+ </div>
724
+ </div>
725
+ );
726
+ }
727
+
728
+ export default AlertSettingsTab;