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:
2026-06-28 14:16:50 +02:00
parent 20432f4b47
commit e9b6f188c8
8 changed files with 114 additions and 31 deletions
@@ -155,12 +155,14 @@ export interface DatabaseInterface {
// Notifications // Notifications
listNotifications(userId: string): DBNotification[]; listNotifications(userId: string): DBNotification[];
getNotification(id: string): DBNotification | null;
createNotification(data: Partial<DBNotification>): DBNotification; createNotification(data: Partial<DBNotification>): DBNotification;
markNotificationRead(id: string): boolean; markNotificationRead(id: string): boolean;
deleteNotification(id: string): boolean; deleteNotification(id: string): boolean;
// Project Shares // Project Shares
listProjectShares(projectId: string): DBProjectShare[]; listProjectShares(projectId: string): DBProjectShare[];
getProjectShare(id: string): DBProjectShare | null;
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare; createProjectShare(data: Partial<DBProjectShare>): DBProjectShare;
deleteProjectShare(id: string): boolean; deleteProjectShare(id: string): boolean;
} }
+8
View File
@@ -272,6 +272,10 @@ export class SqliteAdapter implements DatabaseInterface {
return this.db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC').all(userId) as DBNotification[]; return this.db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC').all(userId) as DBNotification[];
} }
getNotification(id: string): DBNotification | null {
return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification | null;
}
createNotification(data: Partial<DBNotification>): DBNotification { createNotification(data: Partial<DBNotification>): DBNotification {
const id = data.id ?? `notif-${Date.now()}`; const id = data.id ?? `notif-${Date.now()}`;
this.db.prepare( this.db.prepare(
@@ -293,6 +297,10 @@ export class SqliteAdapter implements DatabaseInterface {
return this.db.prepare('SELECT * FROM project_shares WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as DBProjectShare[]; return this.db.prepare('SELECT * FROM project_shares WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as DBProjectShare[];
} }
getProjectShare(id: string): DBProjectShare | null {
return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare | null;
}
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare { createProjectShare(data: Partial<DBProjectShare>): DBProjectShare {
const id = data.id ?? `share-${Date.now()}`; const id = data.id ?? `share-${Date.now()}`;
const shareToken = data.share_token ?? randomUUID(); const shareToken = data.share_token ?? randomUUID();
+14 -6
View File
@@ -21,41 +21,49 @@ export function registerNotificationRoutes(fastify: FastifyInstance, db: Databas
return db.listNotifications(user.id); return db.listNotifications(user.id);
}); });
// Create notification // Create notification (only for self)
fastify.post('/api/notifications', async (request, reply) => { fastify.post('/api/notifications', async (request, reply) => {
const token = extractToken(request); const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Authentication required' }); if (!token) return reply.code(401).send({ error: 'Authentication required' });
const user = authService.getUserFromSession(token); const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const body = request.body as { type?: string; title?: string; message?: string; user_id?: string }; const body = request.body as { type?: string; title?: string; message?: string };
if (!body.title || !body.message) return reply.code(400).send({ error: 'title and message are required' }); if (!body.title || !body.message) return reply.code(400).send({ error: 'title and message are required' });
const validTypes = ['info', 'share', 'warning', 'error'];
const type = validTypes.includes(body.type ?? '') ? body.type! : 'info';
return reply.code(201).send(db.createNotification({ return reply.code(201).send(db.createNotification({
user_id: body.user_id ?? user.id, user_id: user.id,
type: body.type ?? 'info', type,
title: body.title, title: body.title,
message: body.message, message: body.message,
})); }));
}); });
// Mark notification as read // Mark notification as read (ownership check)
fastify.patch('/api/notifications/:id/read', async (request, reply) => { fastify.patch('/api/notifications/:id/read', async (request, reply) => {
const token = extractToken(request); const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Authentication required' }); if (!token) return reply.code(401).send({ error: 'Authentication required' });
const user = authService.getUserFromSession(token); const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const notif = db.getNotification(id);
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const ok = db.markNotificationRead(id); const ok = db.markNotificationRead(id);
if (!ok) return reply.code(404).send({ error: 'Notification not found' }); if (!ok) return reply.code(404).send({ error: 'Notification not found' });
return reply.code(204).send(); return reply.code(204).send();
}); });
// Delete notification // Delete notification (ownership check)
fastify.delete('/api/notifications/:id', async (request, reply) => { fastify.delete('/api/notifications/:id', async (request, reply) => {
const token = extractToken(request); const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Authentication required' }); if (!token) return reply.code(401).send({ error: 'Authentication required' });
const user = authService.getUserFromSession(token); const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const notif = db.getNotification(id);
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const ok = db.deleteNotification(id); const ok = db.deleteNotification(id);
if (!ok) return reply.code(404).send({ error: 'Notification not found' }); if (!ok) return reply.code(404).send({ error: 'Notification not found' });
return reply.code(204).send(); return reply.code(204).send();
+19 -5
View File
@@ -11,32 +11,41 @@ function extractToken(request: any): string | null {
return null; return null;
} }
const VALID_PERMISSIONS = ['view', 'edit', 'admin'];
export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List shares for a project // List shares for a project (owner only)
fastify.get('/api/projects/:projectId/shares', async (request, reply) => { fastify.get('/api/projects/:projectId/shares', async (request, reply) => {
const token = extractToken(request); const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Authentication required' }); if (!token) return reply.code(401).send({ error: 'Authentication required' });
const user = authService.getUserFromSession(token); const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { projectId } = request.params as { projectId: string }; const { projectId } = request.params as { projectId: string };
const project = db.getProject(projectId);
if (!project) return reply.code(404).send({ error: 'Project not found' });
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
return db.listProjectShares(projectId); return db.listProjectShares(projectId);
}); });
// Create a project share // Create a project share (owner only)
fastify.post('/api/projects/:projectId/shares', async (request, reply) => { fastify.post('/api/projects/:projectId/shares', async (request, reply) => {
const token = extractToken(request); const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Authentication required' }); if (!token) return reply.code(401).send({ error: 'Authentication required' });
const user = authService.getUserFromSession(token); const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { projectId } = request.params as { projectId: string }; const { projectId } = request.params as { projectId: string };
const project = db.getProject(projectId);
if (!project) return reply.code(404).send({ error: 'Project not found' });
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const body = request.body as { shared_with_email?: string; permission?: string }; const body = request.body as { shared_with_email?: string; permission?: string };
if (!body.shared_with_email) return reply.code(400).send({ error: 'shared_with_email is required' }); if (!body.shared_with_email) return reply.code(400).send({ error: 'shared_with_email is required' });
const permission = VALID_PERMISSIONS.includes(body.permission ?? '') ? body.permission! : 'view';
const share = db.createProjectShare({ const share = db.createProjectShare({
project_id: projectId, project_id: projectId,
shared_with_email: body.shared_with_email, shared_with_email: body.shared_with_email,
shared_by: user.id, shared_by: user.id,
permission: body.permission ?? 'view', permission,
}); });
// Create a notification for the shared user if they exist // Create a notification for the shared user if they exist
@@ -46,20 +55,25 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf
user_id: sharedUser.id, user_id: sharedUser.id,
type: 'share', type: 'share',
title: 'Projekt geteilt', title: 'Projekt geteilt',
message: `${user.name} hat ein Projekt mit Ihnen geteilt: ${db.getProject(projectId)?.name ?? 'Unbenannt'}`, message: `${user.name} hat ein Projekt mit Ihnen geteilt: ${project.name}`,
}); });
} }
return reply.code(201).send(share); return reply.code(201).send(share);
}); });
// Delete a project share // Delete a project share (owner only)
fastify.delete('/api/shares/:id', async (request, reply) => { fastify.delete('/api/shares/:id', async (request, reply) => {
const token = extractToken(request); const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Authentication required' }); if (!token) return reply.code(401).send({ error: 'Authentication required' });
const user = authService.getUserFromSession(token); const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const share = db.getProjectShare(id);
if (!share) return reply.code(404).send({ error: 'Share not found' });
const project = db.getProject(share.project_id);
if (!project) return reply.code(404).send({ error: 'Project not found' });
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const ok = db.deleteProjectShare(id); const ok = db.deleteProjectShare(id);
if (!ok) return reply.code(404).send({ error: 'Share not found' }); if (!ok) return reply.code(404).send({ error: 'Share not found' });
return reply.code(204).send(); return reply.code(204).send();
+27 -5
View File
@@ -1,7 +1,7 @@
/** /**
* NotificationPanel Shows user notifications with mark-read and delete * 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'; import { getNotifications, markNotificationRead, deleteNotification, type NotificationItem } from '../services/api';
interface NotificationPanelProps { interface NotificationPanelProps {
@@ -12,23 +12,44 @@ export function NotificationPanel({ token }: NotificationPanelProps) {
const [notifications, setNotifications] = useState<NotificationItem[]>([]); const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const fetchNotifications = useCallback(async () => { const fetchNotifications = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null);
try { try {
const data = await getNotifications(token); const data = await getNotifications(token);
setNotifications(data); setNotifications(data);
} catch { } catch {
// ignore setError('Fehler beim Laden');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token]); }, [token]);
// Initial load on mount for badge count
useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
// Refresh when dropdown opens
useEffect(() => { useEffect(() => {
if (open) fetchNotifications(); if (open) fetchNotifications();
}, [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 unreadCount = notifications.filter(n => !n.read).length;
const handleMarkRead = async (id: string) => { const handleMarkRead = async (id: string) => {
@@ -36,7 +57,7 @@ export function NotificationPanel({ token }: NotificationPanelProps) {
await markNotificationRead(token, id); await markNotificationRead(token, id);
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: 1 } : n)); setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: 1 } : n));
} catch { } catch {
// ignore setError('Fehler beim Markieren');
} }
}; };
@@ -45,12 +66,12 @@ export function NotificationPanel({ token }: NotificationPanelProps) {
await deleteNotification(token, id); await deleteNotification(token, id);
setNotifications(prev => prev.filter(n => n.id !== id)); setNotifications(prev => prev.filter(n => n.id !== id));
} catch { } catch {
// ignore setError('Fehler beim Löschen');
} }
}; };
return ( return (
<div className="notification-panel-wrapper"> <div className="notification-panel-wrapper" ref={wrapperRef}>
<button <button
className="notification-bell-btn" className="notification-bell-btn"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
@@ -68,6 +89,7 @@ export function NotificationPanel({ token }: NotificationPanelProps) {
<h3>Benachrichtigungen</h3> <h3>Benachrichtigungen</h3>
<button className="notification-close" onClick={() => setOpen(false)}>×</button> <button className="notification-close" onClick={() => setOpen(false)}>×</button>
</div> </div>
{error && <p className="notification-empty">{error}</p>}
{loading ? ( {loading ? (
<p className="notification-loading">Lädt</p> <p className="notification-loading">Lädt</p>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
+27 -3
View File
@@ -11,20 +11,24 @@ interface ShareDialogProps {
onClose: () => void; onClose: () => void;
} }
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProps) { export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProps) {
const [shares, setShares] = useState<ProjectShare[]>([]); const [shares, setShares] = useState<ProjectShare[]>([]);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [permission, setPermission] = useState('view'); const [permission, setPermission] = useState('view');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const fetchShares = useCallback(async () => { const fetchShares = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null);
try { try {
const data = await getProjectShares(token, projectId); const data = await getProjectShares(token, projectId);
setShares(data); setShares(data);
} catch { } catch {
// ignore setError('Fehler beim Laden der Freigaben');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -36,7 +40,17 @@ export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProp
const handleShare = async () => { const handleShare = async () => {
if (!email.trim()) return; 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); setError(null);
setSubmitting(true);
try { try {
const newShare = await createProjectShare(token, projectId, { const newShare = await createProjectShare(token, projectId, {
shared_with_email: email.trim(), shared_with_email: email.trim(),
@@ -44,8 +58,11 @@ export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProp
}); });
setShares(prev => [newShare, ...prev]); setShares(prev => [newShare, ...prev]);
setEmail(''); setEmail('');
setPermission('view');
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Freigabe fehlgeschlagen'); setError(err.message || 'Freigabe fehlgeschlagen');
} finally {
setSubmitting(false);
} }
}; };
@@ -54,10 +71,14 @@ export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProp
await deleteProjectShare(token, id); await deleteProjectShare(token, id);
setShares(prev => prev.filter(s => s.id !== id)); setShares(prev => prev.filter(s => s.id !== id));
} catch { } catch {
// ignore setError('Fehler beim Entfernen der Freigabe');
} }
}; };
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleShare();
};
if (!open) return null; if (!open) return null;
return ( return (
@@ -74,6 +95,7 @@ export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProp
placeholder="E-Mail-Adresse" placeholder="E-Mail-Adresse"
value={email} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
className="share-email-input" className="share-email-input"
/> />
<select value={permission} onChange={e => setPermission(e.target.value)} className="share-permission-select"> <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="edit">Bearbeitung</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
<button className="share-add-btn" onClick={handleShare}>Teilen</button> <button className="share-add-btn" onClick={handleShare} disabled={submitting}>
{submitting ? '…' : 'Teilen'}
</button>
</div> </div>
{error && <p className="share-error">{error}</p>} {error && <p className="share-error">{error}</p>}
{loading ? ( {loading ? (
+4 -2
View File
@@ -90,7 +90,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
<span className="dashboard-user">{user?.name} ({user?.role})</span> <span className="dashboard-user">{user?.name} ({user?.role})</span>
</div> </div>
<div className="dashboard-header-actions"> <div className="dashboard-header-actions">
<NotificationPanel token={token!} /> {token && <NotificationPanel token={token} />}
<button className="dashboard-logout" onClick={logout}>Abmelden</button> <button className="dashboard-logout" onClick={logout}>Abmelden</button>
</div> </div>
</header> </header>
@@ -160,12 +160,14 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
)} )}
</div> </div>
{token && (
<ShareDialog <ShareDialog
token={token!} token={token}
projectId={shareProjectId ?? ''} projectId={shareProjectId ?? ''}
open={!!shareProjectId} open={!!shareProjectId}
onClose={() => setShareProjectId(null)} onClose={() => setShareProjectId(null)}
/> />
)}
</div> </div>
); );
} }
+8 -5
View File
@@ -404,10 +404,10 @@ export async function getNotifications(token: string): Promise<NotificationItem[
return res.json(); 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`, { const res = await fetch(`${API_BASE}/api/notifications`, {
method: 'POST', method: 'POST',
headers: authHeaders(token), headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!res.ok) throw new Error('Failed to create notification'); 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> { 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', method: 'PATCH',
headers: authHeaders(token), 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> { 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', method: 'DELETE',
headers: authHeaders(token), headers: authHeaders(token),
}); });
if (!res.ok) throw new Error('Failed to delete notification');
} }
// ─── Project Shares ────────────────────────────────────── // ─── 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> { 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', method: 'DELETE',
headers: authHeaders(token), headers: authHeaders(token),
}); });
if (!res.ok) throw new Error('Failed to delete share');
} }
export { API_BASE }; export { API_BASE };