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
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user