feat(notifications): implement notification system with CRUD operations and UI integration
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
This commit is contained in:
@@ -617,6 +617,38 @@ export async function initDatabase() {
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Notifications table ──────────────────────────────────────────────────
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
link TEXT,
|
||||
read INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
link TEXT,
|
||||
read INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Default admin (only on very first start) ────────────────────────────
|
||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||
if (!adminAlreadySeeded) {
|
||||
|
||||
23
server/config/notifications.js
Normal file
23
server/config/notifications.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getDb } from './database.js';
|
||||
|
||||
/**
|
||||
* Create an in-app notification for a user.
|
||||
* Non-fatal — exceptions are swallowed so that the main operation is never blocked.
|
||||
*
|
||||
* @param {number} userId - Recipient user ID
|
||||
* @param {string} type - Notification type (room_share_added | room_share_removed | federation_invite_received)
|
||||
* @param {string} title - Short title (e.g. room name or "from" address)
|
||||
* @param {string|null} body - Optional longer message
|
||||
* @param {string|null} link - Optional frontend path to navigate to when clicked
|
||||
*/
|
||||
export async function createNotification(userId, type, title, body = null, link = null) {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run(
|
||||
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)',
|
||||
[userId, type, title, body, link],
|
||||
);
|
||||
} catch {
|
||||
// Notifications are non-critical — never break main functionality
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import adminRoutes from './routes/admin.js';
|
||||
import brandingRoutes from './routes/branding.js';
|
||||
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
|
||||
import calendarRoutes from './routes/calendar.js';
|
||||
import notificationRoutes from './routes/notifications.js';
|
||||
import { startFederationSync } from './jobs/federationSync.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -55,6 +56,7 @@ async function start() {
|
||||
app.use('/api/branding', brandingRoutes);
|
||||
app.use('/api/federation', federationRoutes);
|
||||
app.use('/api/calendar', calendarRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
// Mount calendar federation receive also under /api/federation for remote instances
|
||||
app.use('/api/federation', calendarRoutes);
|
||||
app.get('/.well-known/redlight', wellKnownHandler);
|
||||
|
||||
@@ -83,6 +83,8 @@ router.get('/', async (req, res) => {
|
||||
const logoFile = findLogoFile();
|
||||
|
||||
const registrationMode = await getSetting('registration_mode');
|
||||
const imprintUrl = await getSetting('imprint_url');
|
||||
const privacyUrl = await getSetting('privacy_url');
|
||||
|
||||
res.json({
|
||||
appName: appName || 'Redlight',
|
||||
@@ -90,6 +92,8 @@ router.get('/', async (req, res) => {
|
||||
logoUrl: logoFile ? '/api/branding/logo' : null,
|
||||
defaultTheme: defaultTheme || null,
|
||||
registrationMode: registrationMode || 'open',
|
||||
imprintUrl: imprintUrl || null,
|
||||
privacyUrl: privacyUrl || null,
|
||||
});
|
||||
} catch (err) {
|
||||
log.branding.error('Get branding error:', err);
|
||||
@@ -210,4 +214,42 @@ router.put('/registration-mode', authenticateToken, requireAdmin, async (req, re
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/branding/imprint-url - Set imprint URL (admin only)
|
||||
router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { imprintUrl } = req.body;
|
||||
if (imprintUrl && imprintUrl.length > 500) {
|
||||
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
||||
}
|
||||
if (imprintUrl && imprintUrl.trim()) {
|
||||
await setSetting('imprint_url', imprintUrl.trim());
|
||||
} else {
|
||||
await deleteSetting('imprint_url');
|
||||
}
|
||||
res.json({ imprintUrl: imprintUrl?.trim() || null });
|
||||
} catch (err) {
|
||||
log.branding.error('Update imprint URL error:', err);
|
||||
res.status(500).json({ error: 'Could not update imprint URL' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/branding/privacy-url - Set privacy policy URL (admin only)
|
||||
router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { privacyUrl } = req.body;
|
||||
if (privacyUrl && privacyUrl.length > 500) {
|
||||
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
||||
}
|
||||
if (privacyUrl && privacyUrl.trim()) {
|
||||
await setSetting('privacy_url', privacyUrl.trim());
|
||||
} else {
|
||||
await deleteSetting('privacy_url');
|
||||
}
|
||||
res.json({ privacyUrl: privacyUrl?.trim() || null });
|
||||
} catch (err) {
|
||||
log.branding.error('Update privacy URL error:', err);
|
||||
res.status(500).json({ error: 'Could not update privacy URL' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { createNotification } from '../config/notifications.js';
|
||||
|
||||
// M13: rate limit the unauthenticated federation receive endpoint
|
||||
const federationReceiveLimiter = rateLimit({
|
||||
@@ -233,6 +234,15 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// In-app notification
|
||||
await createNotification(
|
||||
targetUser.id,
|
||||
'federation_invite_received',
|
||||
from_user,
|
||||
room_name,
|
||||
'/federation/inbox',
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Federation receive error:', err);
|
||||
|
||||
48
server/routes/notifications.js
Normal file
48
server/routes/notifications.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/notifications — List recent notifications for the current user
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const notifications = await db.all(
|
||||
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
|
||||
[req.user.id],
|
||||
);
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
res.json({ notifications, unreadCount });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to load notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/notifications/read-all — Mark all notifications as read
|
||||
// NOTE: Must be declared before /:id/read to avoid routing collision
|
||||
router.post('/read-all', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run('UPDATE notifications SET read = 1 WHERE user_id = ?', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to update notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/notifications/:id/read — Mark a single notification as read
|
||||
router.post('/:id/read', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.run(
|
||||
'UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.user.id],
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to update notification' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,6 +7,7 @@ import { rateLimit } from 'express-rate-limit';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { createNotification } from '../config/notifications.js';
|
||||
import {
|
||||
createMeeting,
|
||||
joinMeeting,
|
||||
@@ -402,6 +403,15 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
`, [room.id]);
|
||||
// Notify the user who was given access
|
||||
const sharerName = req.user.display_name || req.user.name;
|
||||
await createNotification(
|
||||
user_id,
|
||||
'room_share_added',
|
||||
room.name,
|
||||
sharerName,
|
||||
`/rooms/${room.uid}`,
|
||||
);
|
||||
res.json({ shares });
|
||||
} catch (err) {
|
||||
log.rooms.error(`Share room error: ${err.message}`);
|
||||
@@ -417,13 +427,22 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found or no permission' });
|
||||
}
|
||||
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
|
||||
const removedUserId = parseInt(req.params.userId);
|
||||
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, removedUserId]);
|
||||
const shares = await db.all(`
|
||||
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM room_shares rs
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
`, [room.id]);
|
||||
// Notify the user whose access was removed
|
||||
await createNotification(
|
||||
removedUserId,
|
||||
'room_share_removed',
|
||||
room.name,
|
||||
null,
|
||||
'/dashboard',
|
||||
);
|
||||
res.json({ shares });
|
||||
} catch (err) {
|
||||
log.rooms.error(`Remove share error: ${err.message}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import NotificationBell from './NotificationBell';
|
||||
|
||||
export default function Navbar({ onMenuClick }) {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -51,6 +52,9 @@ export default function Navbar({ onMenuClick }) {
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Notification bell */}
|
||||
<NotificationBell />
|
||||
|
||||
{/* User dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
|
||||
167
src/components/NotificationBell.jsx
Normal file
167
src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Bell, BellOff, CheckCheck, ExternalLink } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNotifications } from '../contexts/NotificationContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
function timeAgo(dateStr, lang) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
const h = Math.floor(diff / 3_600_000);
|
||||
const d = Math.floor(diff / 86_400_000);
|
||||
if (lang === 'de') {
|
||||
if (m < 1) return 'gerade eben';
|
||||
if (m < 60) return `vor ${m} Min.`;
|
||||
if (h < 24) return `vor ${h} Std.`;
|
||||
return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
|
||||
}
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${d}d ago`;
|
||||
}
|
||||
|
||||
function notificationIcon(type) {
|
||||
switch (type) {
|
||||
case 'room_share_added': return '🔗';
|
||||
case 'room_share_removed': return '🚫';
|
||||
case 'federation_invite_received': return '📩';
|
||||
default: return '🔔';
|
||||
}
|
||||
}
|
||||
|
||||
function notificationSubtitle(n, t, lang) {
|
||||
switch (n.type) {
|
||||
case 'room_share_added':
|
||||
return n.body
|
||||
? (lang === 'de' ? `Geteilt von ${n.body}` : `Shared by ${n.body}`)
|
||||
: t('notifications.roomShareAdded');
|
||||
case 'room_share_removed':
|
||||
return t('notifications.roomShareRemoved');
|
||||
case 'federation_invite_received':
|
||||
return n.body
|
||||
? (lang === 'de' ? `Raum: ${n.body}` : `Room: ${n.body}`)
|
||||
: t('notifications.federationInviteReceived');
|
||||
default:
|
||||
return n.body || '';
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { notifications, unreadCount, markRead, markAllRead } = useNotifications();
|
||||
const { t, language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
const handleNotificationClick = async (n) => {
|
||||
if (!n.read) await markRead(n.id);
|
||||
if (n.link) navigate(n.link);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const recent = notifications.slice(0, 20);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
{/* Bell button */}
|
||||
<button
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
title={t('notifications.bell')}
|
||||
>
|
||||
<Bell size={20} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 min-w-[16px] h-4 px-0.5 flex items-center justify-center rounded-full bg-th-accent text-th-accent-t text-[10px] font-bold leading-none">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden z-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-th-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={16} className="text-th-accent" />
|
||||
<span className="text-sm font-semibold text-th-text">{t('notifications.bell')}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
title={t('notifications.markAllRead')}
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
{t('notifications.markAllRead')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-th-text-s gap-2">
|
||||
<BellOff size={24} />
|
||||
<span className="text-sm">{t('notifications.noNotifications')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{recent.map(n => (
|
||||
<li
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-th-border/50 last:border-0
|
||||
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span className="text-lg flex-shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm truncate ${n.read ? 'text-th-text-s' : 'text-th-text font-medium'}`}>
|
||||
{n.title}
|
||||
</p>
|
||||
<p className="text-xs text-th-text-s truncate">
|
||||
{notificationSubtitle(n, t, language)}
|
||||
</p>
|
||||
<p className="text-xs text-th-text-s/70 mt-0.5">
|
||||
{timeAgo(n.created_at, language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Unread dot + link indicator */}
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
{!n.read && (
|
||||
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
|
||||
)}
|
||||
{n.link && (
|
||||
<ExternalLink size={12} className="text-th-text-s/50" />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays } from 'lucide-react';
|
||||
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays, FileText, Lock } from 'lucide-react';
|
||||
import BrandLogo from './BrandLogo';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import ThemeSelector from './ThemeSelector';
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
@@ -10,6 +11,7 @@ import api from '../services/api';
|
||||
export default function Sidebar({ open, onClose }) {
|
||||
const { user } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const { imprintUrl, privacyUrl } = useBranding();
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const [federationCount, setFederationCount] = useState(0);
|
||||
|
||||
@@ -122,6 +124,37 @@ export default function Sidebar({ open, onClose }) {
|
||||
<p className="text-xs text-th-text-s truncate">@{user?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Imprint / Privacy Policy links */}
|
||||
{(imprintUrl || privacyUrl) && (
|
||||
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-th-border/60">
|
||||
{imprintUrl && (
|
||||
<a
|
||||
href={imprintUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<FileText size={11} />
|
||||
{t('nav.imprint')}
|
||||
</a>
|
||||
)}
|
||||
{imprintUrl && privacyUrl && (
|
||||
<span className="text-th-border text-xs">·</span>
|
||||
)}
|
||||
{privacyUrl && (
|
||||
<a
|
||||
href={privacyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<Lock size={11} />
|
||||
{t('nav.privacy')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -11,6 +11,8 @@ export function BrandingProvider({ children }) {
|
||||
hasLogo: false,
|
||||
logoUrl: null,
|
||||
defaultTheme: null,
|
||||
imprintUrl: null,
|
||||
privacyUrl: null,
|
||||
});
|
||||
|
||||
const fetchBranding = useCallback(async () => {
|
||||
|
||||
94
src/contexts/NotificationContext.jsx
Normal file
94
src/contexts/NotificationContext.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from './AuthContext';
|
||||
import api from '../services/api';
|
||||
|
||||
const NotificationContext = createContext();
|
||||
|
||||
export function NotificationProvider({ children }) {
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
// Track seen IDs to detect genuinely new arrivals and show toasts
|
||||
const seenIds = useRef(new Set());
|
||||
const initialized = useRef(false);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const res = await api.get('/notifications');
|
||||
const incoming = res.data.notifications || [];
|
||||
setNotifications(incoming);
|
||||
setUnreadCount(res.data.unreadCount || 0);
|
||||
|
||||
// First fetch: just seed the seen-set without toasting
|
||||
if (!initialized.current) {
|
||||
incoming.forEach(n => seenIds.current.add(n.id));
|
||||
initialized.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Subsequent fetches: toast new unread notifications
|
||||
const newItems = incoming.filter(n => !n.read && !seenIds.current.has(n.id));
|
||||
newItems.forEach(n => {
|
||||
seenIds.current.add(n.id);
|
||||
const icon = notificationIcon(n.type);
|
||||
toast(`${icon} ${n.title}`, { duration: 5000 });
|
||||
});
|
||||
} catch {
|
||||
/* silent – server may not be reachable */
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
seenIds.current = new Set();
|
||||
initialized.current = false;
|
||||
return;
|
||||
}
|
||||
fetch();
|
||||
const interval = setInterval(fetch, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [user, fetch]);
|
||||
|
||||
const markRead = async (id) => {
|
||||
try {
|
||||
await api.post(`/notifications/${id}/read`);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, read: 1 } : n)),
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await api.post('/notifications/read-all');
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: 1 })));
|
||||
setUnreadCount(0);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, refresh: fetch }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function notificationIcon(type) {
|
||||
switch (type) {
|
||||
case 'room_share_added': return '🔗';
|
||||
case 'room_share_removed': return '🚫';
|
||||
case 'federation_invite_received': return '📩';
|
||||
default: return '🔔';
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,9 @@
|
||||
"changeTheme": "Theme ändern",
|
||||
"navigation": "Navigation",
|
||||
"calendar": "Kalender",
|
||||
"federation": "Einladungen"
|
||||
"federation": "Einladungen",
|
||||
"imprint": "Impressum",
|
||||
"privacy": "Datenschutz"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -356,7 +358,23 @@
|
||||
"inviteExpired": "Abgelaufen",
|
||||
"inviteUsedBy": "Verwendet von",
|
||||
"inviteExpiresAt": "Läuft ab am",
|
||||
"noInvites": "Noch keine Einladungen"
|
||||
"noInvites": "Noch keine Einladungen",
|
||||
"legalLinksTitle": "Rechtliche Links",
|
||||
"legalLinksDesc": "Impressum- und Datenschutz-Links am unteren Rand der Seitenleiste anzeigen. Leer lassen zum Ausblenden.",
|
||||
"imprintUrl": "Impressum-URL",
|
||||
"privacyUrl": "Datenschutz-URL",
|
||||
"imprintUrlSaved": "Impressum-URL gespeichert",
|
||||
"privacyUrlSaved": "Datenschutz-URL gespeichert",
|
||||
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
|
||||
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Benachrichtigungen",
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
"noNotifications": "Keine Benachrichtigungen",
|
||||
"roomShareAdded": "Raum wurde mit dir geteilt",
|
||||
"roomShareRemoved": "Raumzugriff wurde entfernt",
|
||||
"federationInviteReceived": "Neue Meeting-Einladung"
|
||||
},
|
||||
"federation": {
|
||||
"inbox": "Einladungen",
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
"changeTheme": "Change theme",
|
||||
"navigation": "Navigation",
|
||||
"calendar": "Calendar",
|
||||
"federation": "Invitations"
|
||||
"federation": "Invitations",
|
||||
"imprint": "Imprint",
|
||||
"privacy": "Privacy Policy"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign in",
|
||||
@@ -356,7 +358,23 @@
|
||||
"inviteExpired": "Expired",
|
||||
"inviteUsedBy": "Used by",
|
||||
"inviteExpiresAt": "Expires",
|
||||
"noInvites": "No invitations yet"
|
||||
"noInvites": "No invitations yet",
|
||||
"legalLinksTitle": "Legal Links",
|
||||
"legalLinksDesc": "Show Imprint and Privacy Policy links at the bottom of the sidebar. Leave blank to hide.",
|
||||
"imprintUrl": "Imprint URL",
|
||||
"privacyUrl": "Privacy Policy URL",
|
||||
"imprintUrlSaved": "Imprint URL saved",
|
||||
"privacyUrlSaved": "Privacy Policy URL saved",
|
||||
"imprintUrlFailed": "Could not save Imprint URL",
|
||||
"privacyUrlFailed": "Could not save Privacy Policy URL"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Notifications",
|
||||
"markAllRead": "Mark all as read",
|
||||
"noNotifications": "No notifications yet",
|
||||
"roomShareAdded": "Room shared with you",
|
||||
"roomShareRemoved": "Room access removed",
|
||||
"federationInviteReceived": "New meeting invitation"
|
||||
},
|
||||
"federation": {
|
||||
"inbox": "Invitations",
|
||||
|
||||
27
src/main.jsx
27
src/main.jsx
@@ -7,6 +7,7 @@ import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { BrandingProvider } from './contexts/BrandingContext';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
@@ -16,18 +17,20 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<ThemeProvider>
|
||||
<BrandingProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: 'var(--card-bg)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<NotificationProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: 'var(--card-bg)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</BrandingProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
||||
ShieldCheck, Globe,
|
||||
ShieldCheck, Globe, Link as LinkIcon,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -16,7 +16,7 @@ import toast from 'react-hot-toast';
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const { t, language } = useLanguage();
|
||||
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, refreshBranding } = useBranding();
|
||||
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, refreshBranding } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -43,6 +43,10 @@ export default function Admin() {
|
||||
const logoInputRef = useRef(null);
|
||||
const [editDefaultTheme, setEditDefaultTheme] = useState('');
|
||||
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
|
||||
const [editImprintUrl, setEditImprintUrl] = useState('');
|
||||
const [savingImprintUrl, setSavingImprintUrl] = useState(false);
|
||||
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
|
||||
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'admin') {
|
||||
@@ -61,6 +65,14 @@ export default function Admin() {
|
||||
setEditDefaultTheme(defaultTheme || 'dark');
|
||||
}, [defaultTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditImprintUrl(imprintUrl || '');
|
||||
}, [imprintUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditPrivacyUrl(privacyUrl || '');
|
||||
}, [privacyUrl]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/users');
|
||||
@@ -237,6 +249,32 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImprintUrlSave = async () => {
|
||||
setSavingImprintUrl(true);
|
||||
try {
|
||||
await api.put('/branding/imprint-url', { imprintUrl: editImprintUrl.trim() });
|
||||
toast.success(t('admin.imprintUrlSaved'));
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.imprintUrlFailed'));
|
||||
} finally {
|
||||
setSavingImprintUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivacyUrlSave = async () => {
|
||||
setSavingPrivacyUrl(true);
|
||||
try {
|
||||
await api.put('/branding/privacy-url', { privacyUrl: editPrivacyUrl.trim() });
|
||||
toast.success(t('admin.privacyUrlSaved'));
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.privacyUrlFailed'));
|
||||
} finally {
|
||||
setSavingPrivacyUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
@@ -381,6 +419,59 @@ export default function Admin() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal links */}
|
||||
<div className="mt-6 pt-6 border-t border-th-border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LinkIcon size={16} className="text-th-accent" />
|
||||
<label className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</label>
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Imprint */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={editImprintUrl}
|
||||
onChange={e => setEditImprintUrl(e.target.value)}
|
||||
className="input-field text-sm flex-1"
|
||||
placeholder="https://example.com/imprint"
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
onClick={handleImprintUrlSave}
|
||||
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
|
||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||
>
|
||||
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Privacy Policy */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={editPrivacyUrl}
|
||||
onChange={e => setEditPrivacyUrl(e.target.value)}
|
||||
className="input-field text-sm flex-1"
|
||||
placeholder="https://example.com/privacy"
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
onClick={handlePrivacyUrlSave}
|
||||
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
|
||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||
>
|
||||
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Mode */}
|
||||
|
||||
Reference in New Issue
Block a user