diff --git a/backend/src/database/DatabaseInterface.ts b/backend/src/database/DatabaseInterface.ts index 1b312f9..380eacb 100644 --- a/backend/src/database/DatabaseInterface.ts +++ b/backend/src/database/DatabaseInterface.ts @@ -155,12 +155,14 @@ export interface DatabaseInterface { // Notifications listNotifications(userId: string): DBNotification[]; + getNotification(id: string): DBNotification | null; createNotification(data: Partial): DBNotification; markNotificationRead(id: string): boolean; deleteNotification(id: string): boolean; // Project Shares listProjectShares(projectId: string): DBProjectShare[]; + getProjectShare(id: string): DBProjectShare | null; createProjectShare(data: Partial): DBProjectShare; deleteProjectShare(id: string): boolean; } diff --git a/backend/src/database/SqliteAdapter.ts b/backend/src/database/SqliteAdapter.ts index 7611c21..6fc4ea4 100644 --- a/backend/src/database/SqliteAdapter.ts +++ b/backend/src/database/SqliteAdapter.ts @@ -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 { 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 { const id = data.id ?? `share-${Date.now()}`; const shareToken = data.share_token ?? randomUUID(); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 2dce402..7043139 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -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(); diff --git a/backend/src/routes/shares.ts b/backend/src/routes/shares.ts index 3b99fd5..15ae173 100644 --- a/backend/src/routes/shares.ts +++ b/backend/src/routes/shares.ts @@ -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(); diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index 9a1c1a4..23523bb 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -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([]); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const wrapperRef = useRef(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 ( -
+
+ {error &&

{error}

} {loading ? (

Lädt…

) : notifications.length === 0 ? ( diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 90c0380..e74a091 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -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([]); const [email, setEmail] = useState(''); const [permission, setPermission] = useState('view'); const [loading, setLoading] = useState(false); const [error, setError] = useState(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" /> - +
{error &&

{error}

} {loading ? ( diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 6151a39..1ffddbe 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -90,7 +90,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) { {user?.name} ({user?.role})
- + {token && }
@@ -160,12 +160,14 @@ export function Dashboard({ onOpenProject }: DashboardProps) { )} - setShareProjectId(null)} - /> + {token && ( + setShareProjectId(null)} + /> + )} ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 33732fe..b2f7a16 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -404,10 +404,10 @@ export async function getNotifications(token: string): Promise { +export async function createNotification(token: string, data: { type?: string; title: string; message: string }): Promise { 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 { - 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 { - 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 { - 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 };