From c13090bc80d099c10f143e78f7281c0f84233bbc Mon Sep 17 00:00:00 2001 From: Michelle Date: Mon, 2 Mar 2026 16:45:53 +0100 Subject: [PATCH] feat(notifications): implement notification system with CRUD operations and UI integration --- server/config/database.js | 32 +++++ server/config/notifications.js | 23 ++++ server/index.js | 2 + server/routes/branding.js | 42 +++++++ server/routes/federation.js | 10 ++ server/routes/notifications.js | 48 ++++++++ server/routes/rooms.js | 21 +++- src/components/Navbar.jsx | 4 + src/components/NotificationBell.jsx | 167 +++++++++++++++++++++++++++ src/components/Sidebar.jsx | 35 +++++- src/contexts/BrandingContext.jsx | 2 + src/contexts/NotificationContext.jsx | 94 +++++++++++++++ src/i18n/de.json | 22 +++- src/i18n/en.json | 22 +++- src/main.jsx | 27 +++-- src/pages/Admin.jsx | 95 ++++++++++++++- 16 files changed, 626 insertions(+), 20 deletions(-) create mode 100644 server/config/notifications.js create mode 100644 server/routes/notifications.js create mode 100644 src/components/NotificationBell.jsx create mode 100644 src/contexts/NotificationContext.jsx 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 */}
+ + {/* Dropdown */} + {open && ( +
+ {/* Header */} +
+
+ + {t('notifications.bell')} + {unreadCount > 0 && ( + + {unreadCount} + + )} +
+ {unreadCount > 0 && ( + + )} +
+ + {/* List */} +
+ {recent.length === 0 ? ( +
+ + {t('notifications.noNotifications')} +
+ ) : ( +
    + {recent.map(n => ( +
  • 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 */} + {notificationIcon(n.type)} + + {/* Content */} +
    +

    + {n.title} +

    +

    + {notificationSubtitle(n, t, language)} +

    +

    + {timeAgo(n.created_at, language)} +

    +
    + + {/* Unread dot + link indicator */} +
    + {!n.read && ( + + )} + {n.link && ( + + )} +
    +
  • + ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 428d0eb..70087db 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -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 }) {

@{user?.name}

+ + {/* Imprint / Privacy Policy links */} + {(imprintUrl || privacyUrl) && ( +
+ {imprintUrl && ( + + + {t('nav.imprint')} + + )} + {imprintUrl && privacyUrl && ( + · + )} + {privacyUrl && ( + + + {t('nav.privacy')} + + )} +
+ )} 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} + /> + +
+
+ {/* Privacy Policy */} +
+ +
+ setEditPrivacyUrl(e.target.value)} + className="input-field text-sm flex-1" + placeholder="https://example.com/privacy" + maxLength={500} + /> + +
+
+
+
{/* Registration Mode */}