diff --git a/server/config/database.js b/server/config/database.js
index 290c9c2..11d62d2 100644
--- a/server/config/database.js
+++ b/server/config/database.js
@@ -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) {
diff --git a/server/config/notifications.js b/server/config/notifications.js
new file mode 100644
index 0000000..a821b9e
--- /dev/null
+++ b/server/config/notifications.js
@@ -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
+ }
+}
diff --git a/server/index.js b/server/index.js
index cd6e797..c772e62 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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);
diff --git a/server/routes/branding.js b/server/routes/branding.js
index fb3a84d..c6ca400 100644
--- a/server/routes/branding.js
+++ b/server/routes/branding.js
@@ -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;
diff --git a/server/routes/federation.js b/server/routes/federation.js
index 4f511eb..ff80809 100644
--- a/server/routes/federation.js
+++ b/server/routes/federation.js
@@ -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);
diff --git a/server/routes/notifications.js b/server/routes/notifications.js
new file mode 100644
index 0000000..6bb7464
--- /dev/null
+++ b/server/routes/notifications.js
@@ -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;
diff --git a/server/routes/rooms.js b/server/routes/rooms.js
index c3dc5e0..0261d9c 100644
--- a/server/routes/rooms.js
+++ b/server/routes/rooms.js
@@ -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}`);
diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx
index 01549ad..1da8857 100644
--- a/src/components/Navbar.jsx
+++ b/src/components/Navbar.jsx
@@ -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 */}
+ {/* Notification bell */}
+
+
{/* User dropdown */}
+
+ {/* Imprint / Privacy Policy links */}
+ {(imprintUrl || privacyUrl) && (
+
+ )}
diff --git a/src/contexts/BrandingContext.jsx b/src/contexts/BrandingContext.jsx
index ec62b65..6f8102d 100644
--- a/src/contexts/BrandingContext.jsx
+++ b/src/contexts/BrandingContext.jsx
@@ -11,6 +11,8 @@ export function BrandingProvider({ children }) {
hasLogo: false,
logoUrl: null,
defaultTheme: null,
+ imprintUrl: null,
+ privacyUrl: null,
});
const fetchBranding = useCallback(async () => {
diff --git a/src/contexts/NotificationContext.jsx b/src/contexts/NotificationContext.jsx
new file mode 100644
index 0000000..f450d50
--- /dev/null
+++ b/src/contexts/NotificationContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+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 '🔔';
+ }
+}
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 51afac3..8d21a7b 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -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",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 12b4399..d963206 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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",
diff --git a/src/main.jsx b/src/main.jsx
index 3e5da17..b18a040 100644
--- a/src/main.jsx
+++ b/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(
-
-
+
+
+
+
diff --git a/src/pages/Admin.jsx b/src/pages/Admin.jsx
index db6ed0c..fe451d7 100644
--- a/src/pages/Admin.jsx
+++ b/src/pages/Admin.jsx
@@ -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() {
+
+ {/* Legal links */}
+
+
+
+
+
+
{t('admin.legalLinksDesc')}
+
+ {/* Imprint */}
+
+
+
+ setEditImprintUrl(e.target.value)}
+ className="input-field text-sm flex-1"
+ placeholder="https://example.com/imprint"
+ maxLength={500}
+ />
+
+ {savingImprintUrl ? : t('common.save')}
+
+
+
+ {/* Privacy Policy */}
+
+
+
+ setEditPrivacyUrl(e.target.value)}
+ className="input-field text-sm flex-1"
+ placeholder="https://example.com/privacy"
+ maxLength={500}
+ />
+
+ {savingPrivacyUrl ? : t('common.save')}
+
+
+
+
+
{/* Registration Mode */}