fix: security and UX improvements for notifications and shares
- Add ownership checks on all notification and share routes (403 Forbidden) - Validate permission field (only view/edit/admin allowed) - Remove user_id from POST notifications (only self-notifications) - Add getNotification/getProjectShare to DB interface + adapter - Add res.ok checks on all frontend API calls - Add click-outside handler for notification dropdown - Add initial notification load on mount for badge count - Add email validation + duplicate check in ShareDialog - Add Enter key handler in ShareDialog - Add submitting state to prevent double-click - Guard against null token in Dashboard
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* NotificationPanel – Shows user notifications with mark-read and delete
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getNotifications, markNotificationRead, deleteNotification, type NotificationItem } from '../services/api';
|
||||
|
||||
interface NotificationPanelProps {
|
||||
@@ -12,23 +12,44 @@ export function NotificationPanel({ token }: NotificationPanelProps) {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getNotifications(token);
|
||||
setNotifications(data);
|
||||
} catch {
|
||||
// ignore
|
||||
setError('Fehler beim Laden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Initial load on mount for badge count
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
// Refresh when dropdown opens
|
||||
useEffect(() => {
|
||||
if (open) fetchNotifications();
|
||||
}, [open, fetchNotifications]);
|
||||
|
||||
// Click-outside handler
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
@@ -36,7 +57,7 @@ export function NotificationPanel({ token }: NotificationPanelProps) {
|
||||
await markNotificationRead(token, id);
|
||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: 1 } : n));
|
||||
} catch {
|
||||
// ignore
|
||||
setError('Fehler beim Markieren');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,12 +66,12 @@ export function NotificationPanel({ token }: NotificationPanelProps) {
|
||||
await deleteNotification(token, id);
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
} catch {
|
||||
// ignore
|
||||
setError('Fehler beim Löschen');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notification-panel-wrapper">
|
||||
<div className="notification-panel-wrapper" ref={wrapperRef}>
|
||||
<button
|
||||
className="notification-bell-btn"
|
||||
onClick={() => setOpen(!open)}
|
||||
@@ -68,6 +89,7 @@ export function NotificationPanel({ token }: NotificationPanelProps) {
|
||||
<h3>Benachrichtigungen</h3>
|
||||
<button className="notification-close" onClick={() => setOpen(false)}>×</button>
|
||||
</div>
|
||||
{error && <p className="notification-empty">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="notification-loading">Lädt…</p>
|
||||
) : notifications.length === 0 ? (
|
||||
|
||||
@@ -11,20 +11,24 @@ interface ShareDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProps) {
|
||||
const [shares, setShares] = useState<ProjectShare[]>([]);
|
||||
const [email, setEmail] = useState('');
|
||||
const [permission, setPermission] = useState('view');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetchShares = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getProjectShares(token, projectId);
|
||||
setShares(data);
|
||||
} catch {
|
||||
// ignore
|
||||
setError('Fehler beim Laden der Freigaben');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -36,7 +40,17 @@ export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProp
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!email.trim()) return;
|
||||
if (!EMAIL_REGEX.test(email.trim())) {
|
||||
setError('Ungültige E-Mail-Adresse');
|
||||
return;
|
||||
}
|
||||
// Check for duplicate share
|
||||
if (shares.some(s => s.shared_with_email === email.trim())) {
|
||||
setError('Projekt bereits mit dieser E-Mail geteilt');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const newShare = await createProjectShare(token, projectId, {
|
||||
shared_with_email: email.trim(),
|
||||
@@ -44,8 +58,11 @@ export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProp
|
||||
});
|
||||
setShares(prev => [newShare, ...prev]);
|
||||
setEmail('');
|
||||
setPermission('view');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Freigabe fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,10 +71,14 @@ export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProp
|
||||
await deleteProjectShare(token, id);
|
||||
setShares(prev => prev.filter(s => s.id !== id));
|
||||
} catch {
|
||||
// ignore
|
||||
setError('Fehler beim Entfernen der Freigabe');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleShare();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
@@ -74,6 +95,7 @@ export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProp
|
||||
placeholder="E-Mail-Adresse"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="share-email-input"
|
||||
/>
|
||||
<select value={permission} onChange={e => setPermission(e.target.value)} className="share-permission-select">
|
||||
@@ -81,7 +103,9 @@ export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProp
|
||||
<option value="edit">Bearbeitung</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button className="share-add-btn" onClick={handleShare}>Teilen</button>
|
||||
<button className="share-add-btn" onClick={handleShare} disabled={submitting}>
|
||||
{submitting ? '…' : 'Teilen'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="share-error">{error}</p>}
|
||||
{loading ? (
|
||||
|
||||
@@ -90,7 +90,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
|
||||
<span className="dashboard-user">{user?.name} ({user?.role})</span>
|
||||
</div>
|
||||
<div className="dashboard-header-actions">
|
||||
<NotificationPanel token={token!} />
|
||||
{token && <NotificationPanel token={token} />}
|
||||
<button className="dashboard-logout" onClick={logout}>Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -160,12 +160,14 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ShareDialog
|
||||
token={token!}
|
||||
projectId={shareProjectId ?? ''}
|
||||
open={!!shareProjectId}
|
||||
onClose={() => setShareProjectId(null)}
|
||||
/>
|
||||
{token && (
|
||||
<ShareDialog
|
||||
token={token}
|
||||
projectId={shareProjectId ?? ''}
|
||||
open={!!shareProjectId}
|
||||
onClose={() => setShareProjectId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -404,10 +404,10 @@ export async function getNotifications(token: string): Promise<NotificationItem[
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createNotification(token: string, data: { type?: string; title: string; message: string; user_id?: string }): Promise<NotificationItem> {
|
||||
export async function createNotification(token: string, data: { type?: string; title: string; message: string }): Promise<NotificationItem> {
|
||||
const res = await fetch(`${API_BASE}/api/notifications`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create notification');
|
||||
@@ -415,17 +415,19 @@ export async function createNotification(token: string, data: { type?: string; t
|
||||
}
|
||||
|
||||
export async function markNotificationRead(token: string, id: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/notifications/${id}/read`, {
|
||||
const res = await fetch(`${API_BASE}/api/notifications/${id}/read`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to mark notification as read');
|
||||
}
|
||||
|
||||
export async function deleteNotification(token: string, id: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/notifications/${id}`, {
|
||||
const res = await fetch(`${API_BASE}/api/notifications/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete notification');
|
||||
}
|
||||
|
||||
// ─── Project Shares ──────────────────────────────────────
|
||||
@@ -456,10 +458,11 @@ export async function createProjectShare(token: string, projectId: string, data:
|
||||
}
|
||||
|
||||
export async function deleteProjectShare(token: string, shareId: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/shares/${shareId}`, {
|
||||
const res = await fetch(`${API_BASE}/api/shares/${shareId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete share');
|
||||
}
|
||||
|
||||
export { API_BASE };
|
||||
|
||||
Reference in New Issue
Block a user