Security-Hardening Runde 10: Security-Monitoring + Alerting
Defense-in-Depth für alles, was in den ersten 9 Runden nicht durch Code verhindert wurde: zumindest gesehen + alarmiert werden. 📊 SecurityEvent-Tabelle (Prisma) - Type/Severity/IP/User/Endpoint + Indexen für Filter+Threshold-Detection - Trennt sich vom AuditLog: AuditLog ist forensisch + hash-gekettet, SecurityEvent ist optimiert für Realtime-Alerting + Aggregation. 🪝 Hooks an kritischen Stellen - Login (Success/Failed) – auth.controller - Logout, Password-Reset (Request + Confirm) – auth.controller - Rate-Limit-Hit – middleware/rateLimit - IDOR-403 – utils/accessControl (canAccessCustomer / canAccessContract) - SSRF-Block – emailProvider.controller (test-connection + test-mail-access) - JWT-Reject (alg=none, expired, manipuliert) – middleware/auth 🚨 Threshold-Detection + Alerting (securityAlert.service.ts) - Cron jede Minute: prüft Brute-Force-Patterns je IP - 10× LOGIN_FAILED in 60 min → CRITICAL Brute-Force-Verdacht - 5× ACCESS_DENIED in 5 min → CRITICAL IDOR-Probing-Verdacht - 3× SSRF_BLOCKED in 60 min → CRITICAL SSRF-Probing - 3× TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation - CRITICAL-Events: Sofort-Alert per E-Mail (debounced) - Cron stündlich: Digest mit HIGH+MEDIUM-Events (wenn aktiviert) - Sofort-Alert + Digest laufen über System-E-Mail-Provider (gleicher Pfad wie Geburtstagsgrüße, Passwort-Reset) 🖥 Frontend: Settings → "Sicherheits-Monitoring" - Alert-E-Mail-Adresse + Digest-Toggle - Test-Alert-Button + Digest-jetzt-Button - Stats-Cards pro Severity (CRITICAL/HIGH/MEDIUM/LOW/INFO) - Filter (Type/Severity/Search/IP) + Pagination - Auto-Refresh alle 30 s - Verlinkt aus Settings-Übersicht (settings:read Permission) 🧪 Live-verifiziert - Login-Fehlversuch → LOGIN_FAILED Event - Portal probt 4× fremde Customer-IDs → 4× ACCESS_DENIED - SSRF-Probe (169.254.169.254) → SSRF_BLOCKED Event - 12× LOGIN_FAILED simuliert → Cron erzeugt CRITICAL nach ≤60s - CRITICAL-Sofort-Alert binnen 30s zugestellt - Test-Alert-Button: E-Mail zugestellt - Hourly-Digest mit 5 Events: E-Mail mit Tabelle zugestellt Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ import DatabaseBackup from './pages/settings/DatabaseBackup';
|
||||
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
||||
import AuditLogs from './pages/settings/AuditLogs';
|
||||
import EmailLogPage from './pages/settings/EmailLogs';
|
||||
import Monitoring from './pages/settings/Monitoring';
|
||||
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
||||
import UserList from './pages/users/UserList';
|
||||
import Settings from './pages/Settings';
|
||||
@@ -202,6 +203,7 @@ function App() {
|
||||
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
||||
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
||||
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
||||
<Route path="settings/monitoring" element={<Monitoring />} />
|
||||
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
||||
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
||||
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Card from '../components/ui/Card';
|
||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit, PackageCheck } from 'lucide-react';
|
||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, FileText, FileEdit, PackageCheck } from 'lucide-react';
|
||||
|
||||
export default function Settings() {
|
||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||
@@ -238,6 +238,27 @@ export default function Settings() {
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('settings:read') && (
|
||||
<Link
|
||||
to="/settings/monitoring"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-orange-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-orange-50 rounded-lg group-hover:bg-orange-100 transition-colors">
|
||||
<ShieldAlert className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-orange-600 transition-colors flex items-center gap-2">
|
||||
Sicherheits-Monitoring
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Login-Fehlversuche, IDOR-Abwehr, SSRF-Blocks etc. + Alert-E-Mail-Adresse konfigurieren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('gdpr:admin') && (
|
||||
<Link
|
||||
to="/settings/gdpr"
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { monitoringApi, type SecurityEventType, type SecuritySeverity } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import { ArrowLeft, Send, RefreshCw, Mail, ShieldAlert, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
|
||||
{ value: '', label: 'Alle Typen' },
|
||||
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
|
||||
{ value: 'LOGIN_SUCCESS', label: 'Login erfolgreich' },
|
||||
{ value: 'RATE_LIMIT_HIT', label: 'Rate-Limit greift' },
|
||||
{ value: 'ACCESS_DENIED', label: 'Zugriff verweigert (IDOR)' },
|
||||
{ value: 'SSRF_BLOCKED', label: 'SSRF blockiert' },
|
||||
{ value: 'PASSWORD_RESET_REQUEST', label: 'Passwort-Reset angefordert' },
|
||||
{ value: 'PASSWORD_RESET_CONFIRM', label: 'Passwort-Reset bestätigt' },
|
||||
{ value: 'LOGOUT', label: 'Logout' },
|
||||
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
|
||||
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
|
||||
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
|
||||
];
|
||||
|
||||
const SEVERITY_OPTIONS: { value: SecuritySeverity | ''; label: string }[] = [
|
||||
{ value: '', label: 'Alle Stufen' },
|
||||
{ value: 'INFO', label: 'Info' },
|
||||
{ value: 'LOW', label: 'Niedrig' },
|
||||
{ value: 'MEDIUM', label: 'Mittel' },
|
||||
{ value: 'HIGH', label: 'Hoch' },
|
||||
{ value: 'CRITICAL', label: 'Kritisch' },
|
||||
];
|
||||
|
||||
function severityClass(s: SecuritySeverity): string {
|
||||
switch (s) {
|
||||
case 'CRITICAL': return 'bg-red-100 text-red-800 border border-red-300';
|
||||
case 'HIGH': return 'bg-orange-100 text-orange-800 border border-orange-300';
|
||||
case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border border-yellow-300';
|
||||
case 'LOW': return 'bg-blue-100 text-blue-800 border border-blue-300';
|
||||
default: return 'bg-gray-100 text-gray-700 border border-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
function severityIcon(s: SecuritySeverity): string {
|
||||
switch (s) {
|
||||
case 'CRITICAL': return '🚨';
|
||||
case 'HIGH': return '⚠️';
|
||||
case 'MEDIUM': return '🟡';
|
||||
case 'LOW': return '🟢';
|
||||
default: return 'ℹ️';
|
||||
}
|
||||
}
|
||||
|
||||
export default function Monitoring() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState({
|
||||
type: '' as SecurityEventType | '',
|
||||
severity: '' as SecuritySeverity | '',
|
||||
search: '',
|
||||
ip: '',
|
||||
});
|
||||
|
||||
const [alertEmail, setAlertEmail] = useState('');
|
||||
const [digestEnabled, setDigestEnabled] = useState(false);
|
||||
|
||||
// Settings laden
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['monitoring-settings'],
|
||||
queryFn: monitoringApi.getSettings,
|
||||
});
|
||||
|
||||
// States nach Laden synchronisieren (nur initial)
|
||||
if (settingsData?.data && alertEmail === '' && settingsData.data.alertEmail !== '') {
|
||||
setAlertEmail(settingsData.data.alertEmail);
|
||||
setDigestEnabled(settingsData.data.digestEnabled);
|
||||
}
|
||||
|
||||
// Events laden
|
||||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['monitoring-events', page, filters],
|
||||
queryFn: () => monitoringApi.getEvents({ page, limit: 50, ...filters }),
|
||||
refetchInterval: 30_000, // alle 30s neu laden
|
||||
});
|
||||
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: () => monitoringApi.updateSettings({ alertEmail, digestEnabled }),
|
||||
onSuccess: () => {
|
||||
toast.success('Einstellungen gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['monitoring-settings'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || 'Speichern fehlgeschlagen'),
|
||||
});
|
||||
|
||||
const testAlert = useMutation({
|
||||
mutationFn: () => monitoringApi.testAlert(),
|
||||
onSuccess: (res) => toast.success(res.message || 'Test-Alert versendet'),
|
||||
onError: (e: Error) => toast.error(e.message || 'Test fehlgeschlagen'),
|
||||
});
|
||||
|
||||
const runDigest = useMutation({
|
||||
mutationFn: () => monitoringApi.runDigest(),
|
||||
onSuccess: (res) => {
|
||||
const r = res.data;
|
||||
if (r?.sent) toast.success(`Digest mit ${r.eventCount} Events versendet`);
|
||||
else toast(r?.reason || 'Kein Digest versendet', { icon: 'ℹ️' });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || 'Digest fehlgeschlagen'),
|
||||
});
|
||||
|
||||
const events = eventsData?.data || [];
|
||||
const stats = eventsData?.stats;
|
||||
const pagination = eventsData?.pagination;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/settings')} className="mb-2">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Zurück zu Einstellungen
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldAlert className="w-6 h-6 text-orange-500" /> Sicherheits-Monitoring
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
Sicherheitsrelevante Ereignisse + Alert-Einstellungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<Card className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" /> Alert-Empfänger
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<Input
|
||||
label="E-Mail-Adresse für Alerts"
|
||||
type="email"
|
||||
value={alertEmail}
|
||||
onChange={(e) => setAlertEmail(e.target.value)}
|
||||
placeholder="security@deine-firma.de"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Leer lassen, um Alerts zu deaktivieren.</p>
|
||||
</div>
|
||||
<div className="flex items-end gap-3">
|
||||
<label className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={digestEnabled}
|
||||
onChange={(e) => setDigestEnabled(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Stündlicher Digest (HIGH+MEDIUM Events)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => testAlert.mutate()} disabled={!alertEmail || testAlert.isPending}>
|
||||
<Send className="w-4 h-4 mr-1" /> Test-Alert senden
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => runDigest.mutate()} disabled={!alertEmail || runDigest.isPending}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" /> Digest jetzt ausführen
|
||||
</Button>
|
||||
{settingsData?.data?.lastDigestAt && (
|
||||
<span className="text-xs text-gray-500 self-center">
|
||||
Letzter Digest: {new Date(settingsData.data.lastDigestAt).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
<strong>Sofort-Alert:</strong> CRITICAL-Events (z.B. Brute-Force-Verdacht) werden binnen 1 Minute per
|
||||
E-Mail versendet.<br />
|
||||
<strong>Digest:</strong> HIGH+MEDIUM-Events werden zur vollen Stunde gesammelt verschickt (wenn aktiviert).
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stats-Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'] as SecuritySeverity[]).map((sev) => (
|
||||
<Card key={sev}>
|
||||
<div className={`text-xs font-semibold ${severityClass(sev).split(' ').filter((c) => c.startsWith('text-'))[0]}`}>
|
||||
{severityIcon(sev)} {sev}
|
||||
</div>
|
||||
<div className="text-2xl font-bold mt-1">{stats.bySeverity[sev] || 0}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<Card className="mb-4">
|
||||
<div className="grid sm:grid-cols-4 gap-3">
|
||||
<Select
|
||||
label="Typ"
|
||||
value={filters.type}
|
||||
onChange={(e) => { setFilters((f) => ({ ...f, type: e.target.value as any })); setPage(1); }}
|
||||
options={TYPE_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
label="Severity"
|
||||
value={filters.severity}
|
||||
onChange={(e) => { setFilters((f) => ({ ...f, severity: e.target.value as any })); setPage(1); }}
|
||||
options={SEVERITY_OPTIONS}
|
||||
/>
|
||||
<Input
|
||||
label="Suche (Nachricht/User/Endpoint)"
|
||||
value={filters.search}
|
||||
onChange={(e) => { setFilters((f) => ({ ...f, search: e.target.value })); setPage(1); }}
|
||||
placeholder="z.B. admin@admin.com"
|
||||
/>
|
||||
<Input
|
||||
label="IP-Adresse"
|
||||
value={filters.ip}
|
||||
onChange={(e) => { setFilters((f) => ({ ...f, ip: e.target.value })); setPage(1); }}
|
||||
placeholder="z.B. 1.2.3.4"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabelle */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-3">Events</h2>
|
||||
{eventsLoading ? (
|
||||
<div className="text-gray-500 py-4">Lade…</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="text-gray-500 py-8 text-center">Keine Events für diese Filter.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left">
|
||||
<tr>
|
||||
<th className="px-3 py-2 whitespace-nowrap">Zeit</th>
|
||||
<th className="px-3 py-2">Severity</th>
|
||||
<th className="px-3 py-2">Typ</th>
|
||||
<th className="px-3 py-2">Nachricht</th>
|
||||
<th className="px-3 py-2">Wer</th>
|
||||
<th className="px-3 py-2">IP</th>
|
||||
<th className="px-3 py-2">Endpoint</th>
|
||||
<th className="px-3 py-2">Alert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.id} className="border-t hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
|
||||
{new Date(e.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${severityClass(e.severity)}`}>
|
||||
{severityIcon(e.severity)} {e.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{e.type}</td>
|
||||
<td className="px-3 py-2">{e.message}</td>
|
||||
<td className="px-3 py-2 text-xs">{e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–')}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{e.ipAddress || '–'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{e.endpoint || '–'}</td>
|
||||
<td className="px-3 py-2 text-xs">{e.alerted ? '✉️ ja' : '–'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4 text-sm">
|
||||
<span>Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||
<ChevronLeft className="w-4 h-4" /> Zurück
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page >= pagination.totalPages}>
|
||||
Weiter <ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1426,6 +1426,71 @@ export interface EmailLog {
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
// ==================== MONITORING ====================
|
||||
|
||||
export type SecurityEventType =
|
||||
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
|
||||
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
|
||||
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS';
|
||||
|
||||
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
|
||||
export interface SecurityEvent {
|
||||
id: number;
|
||||
type: SecurityEventType;
|
||||
severity: SecuritySeverity;
|
||||
message: string;
|
||||
ipAddress: string | null;
|
||||
userId: number | null;
|
||||
customerId: number | null;
|
||||
userEmail: string | null;
|
||||
endpoint: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
alerted: boolean;
|
||||
alertedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MonitoringSettings {
|
||||
alertEmail: string;
|
||||
digestEnabled: boolean;
|
||||
lastDigestAt: string | null;
|
||||
}
|
||||
|
||||
export const monitoringApi = {
|
||||
getEvents: async (params?: { page?: number; limit?: number; type?: SecurityEventType | ''; severity?: SecuritySeverity | ''; search?: string; ip?: string; since?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.limit) q.set('limit', String(params.limit));
|
||||
if (params?.type) q.set('type', params.type);
|
||||
if (params?.severity) q.set('severity', params.severity);
|
||||
if (params?.search) q.set('search', params.search);
|
||||
if (params?.ip) q.set('ip', params.ip);
|
||||
if (params?.since) q.set('since', params.since);
|
||||
const res = await api.get<ApiResponse<SecurityEvent[]> & {
|
||||
pagination: { page: number; limit: number; total: number; totalPages: number };
|
||||
stats: { byType: Record<string, number>; bySeverity: Record<string, number> };
|
||||
}>(`/monitoring/events?${q}`);
|
||||
return res.data;
|
||||
},
|
||||
getSettings: async () => {
|
||||
const res = await api.get<ApiResponse<MonitoringSettings>>('/monitoring/settings');
|
||||
return res.data;
|
||||
},
|
||||
updateSettings: async (data: { alertEmail?: string; digestEnabled?: boolean }) => {
|
||||
const res = await api.put<ApiResponse<void>>('/monitoring/settings', data);
|
||||
return res.data;
|
||||
},
|
||||
testAlert: async () => {
|
||||
const res = await api.post<ApiResponse<void>>('/monitoring/test-alert');
|
||||
return res.data;
|
||||
},
|
||||
runDigest: async () => {
|
||||
const res = await api.post<ApiResponse<{ sent: boolean; eventCount: number; reason?: string }>>('/monitoring/run-digest');
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const emailLogApi = {
|
||||
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
Reference in New Issue
Block a user