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:
@@ -155,12 +155,14 @@ export interface DatabaseInterface {
|
||||
|
||||
// Notifications
|
||||
listNotifications(userId: string): DBNotification[];
|
||||
getNotification(id: string): DBNotification | null;
|
||||
createNotification(data: Partial<DBNotification>): DBNotification;
|
||||
markNotificationRead(id: string): boolean;
|
||||
deleteNotification(id: string): boolean;
|
||||
|
||||
// Project Shares
|
||||
listProjectShares(projectId: string): DBProjectShare[];
|
||||
getProjectShare(id: string): DBProjectShare | null;
|
||||
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare;
|
||||
deleteProjectShare(id: string): boolean;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
getNotification(id: string): DBNotification | null {
|
||||
return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification | null;
|
||||
}
|
||||
|
||||
createNotification(data: Partial<DBNotification>): DBNotification {
|
||||
const id = data.id ?? `notif-${Date.now()}`;
|
||||
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[];
|
||||
}
|
||||
|
||||
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 {
|
||||
const id = data.id ?? `share-${Date.now()}`;
|
||||
const shareToken = data.share_token ?? randomUUID();
|
||||
|
||||
@@ -21,41 +21,49 @@ export function registerNotificationRoutes(fastify: FastifyInstance, db: Databas
|
||||
return db.listNotifications(user.id);
|
||||
});
|
||||
|
||||
// Create notification
|
||||
// Create notification (only for self)
|
||||
fastify.post('/api/notifications', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
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' });
|
||||
const validTypes = ['info', 'share', 'warning', 'error'];
|
||||
const type = validTypes.includes(body.type ?? '') ? body.type! : 'info';
|
||||
return reply.code(201).send(db.createNotification({
|
||||
user_id: body.user_id ?? user.id,
|
||||
type: body.type ?? 'info',
|
||||
user_id: user.id,
|
||||
type,
|
||||
title: body.title,
|
||||
message: body.message,
|
||||
}));
|
||||
});
|
||||
|
||||
// Mark notification as read
|
||||
// Mark notification as read (ownership check)
|
||||
fastify.patch('/api/notifications/:id/read', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
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);
|
||||
if (!ok) return reply.code(404).send({ error: 'Notification not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Delete notification
|
||||
// Delete notification (ownership check)
|
||||
fastify.delete('/api/notifications/:id', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
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);
|
||||
if (!ok) return reply.code(404).send({ error: 'Notification not found' });
|
||||
return reply.code(204).send();
|
||||
|
||||
@@ -11,32 +11,41 @@ function extractToken(request: any): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const VALID_PERMISSIONS = ['view', 'edit', 'admin'];
|
||||
|
||||
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) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
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);
|
||||
});
|
||||
|
||||
// Create a project share
|
||||
// Create a project share (owner only)
|
||||
fastify.post('/api/projects/:projectId/shares', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
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 };
|
||||
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({
|
||||
project_id: projectId,
|
||||
shared_with_email: body.shared_with_email,
|
||||
shared_by: user.id,
|
||||
permission: body.permission ?? 'view',
|
||||
permission,
|
||||
});
|
||||
|
||||
// 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,
|
||||
type: 'share',
|
||||
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);
|
||||
});
|
||||
|
||||
// Delete a project share
|
||||
// Delete a project share (owner only)
|
||||
fastify.delete('/api/shares/:id', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
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);
|
||||
if (!ok) return reply.code(404).send({ error: 'Share not found' });
|
||||
return reply.code(204).send();
|
||||
|
||||
@@ -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