From 28af4cb068e68d1a1f5265597c3887669846553f Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 26 Jan 2026 01:29:24 +0100 Subject: [PATCH] mandate invitation and notification system --- .../Navigation/UserSection.module.css | 11 +- src/components/Navigation/UserSection.tsx | 4 + .../NotificationBell.module.css | 368 ++++++++++++++++++ .../NotificationBell/NotificationBell.tsx | 257 ++++++++++++ src/components/NotificationBell/index.ts | 2 + src/hooks/useInvitations.ts | 4 + src/hooks/useNotifications.ts | 277 +++++++++++++ src/pages/InvitePage.tsx | 50 ++- src/pages/Login.tsx | 2 +- src/pages/Register.tsx | 2 +- src/pages/admin/AdminInvitationsPage.tsx | 33 +- 11 files changed, 990 insertions(+), 20 deletions(-) create mode 100644 src/components/NotificationBell/NotificationBell.module.css create mode 100644 src/components/NotificationBell/NotificationBell.tsx create mode 100644 src/components/NotificationBell/index.ts create mode 100644 src/hooks/useNotifications.ts diff --git a/src/components/Navigation/UserSection.module.css b/src/components/Navigation/UserSection.module.css index 33da0be..868f779 100644 --- a/src/components/Navigation/UserSection.module.css +++ b/src/components/Navigation/UserSection.module.css @@ -4,15 +4,24 @@ .userSection { position: relative; + display: flex; + align-items: center; + gap: 0.5rem; padding: 0.5rem; border-top: 1px solid var(--border-color, #e0e0e0); } +/* Notification Bell */ +.notificationBell { + flex-shrink: 0; +} + .userButton { + flex: 1; + min-width: 0; display: flex; align-items: center; gap: 0.75rem; - width: 100%; padding: 0.5rem; border: none; border-radius: 8px; diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index 3fd0c7c..b78b874 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -8,6 +8,7 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCurrentUser } from '../../hooks/useUsers'; import { useMsal } from '@azure/msal-react'; +import { NotificationBell } from '../NotificationBell'; import styles from './UserSection.module.css'; export const UserSection: React.FC = () => { @@ -49,6 +50,9 @@ export const UserSection: React.FC = () => { return (
+ {/* Notification Bell */} + + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Header */} +
+

Benachrichtigungen

+ {visibleNotifications.some(n => n.status === 'unread') && ( + + )} +
+ + {/* Content */} +
+ {loading && visibleNotifications.length === 0 && ( +
Lade...
+ )} + + {error && ( +
{error}
+ )} + + {!loading && !error && visibleNotifications.length === 0 && ( +
+ +

Keine Benachrichtigungen

+
+ )} + + {visibleNotifications.map(notification => ( +
handleNotificationClick(notification)} + > + {/* Success overlay */} + {actionSuccess === notification.id && ( +
+ + {notification.actionResult || 'Erfolgreich'} +
+ )} + + {/* Icon */} +
+ {typeIcons[notification.type] || } +
+ + {/* Content */} +
+
{notification.title}
+
{notification.message}
+
{formatRelativeTime(notification.createdAt)}
+ + {/* Actions */} + {notification.actions && notification.status !== 'actioned' && ( +
+ {notification.actions.map(action => ( + + ))} +
+ )} + + {/* Action result */} + {notification.actionTaken && ( +
+ {notification.actionResult} +
+ )} +
+ + {/* Dismiss button */} + {notification.status !== 'actioned' && ( + + )} +
+ ))} +
+
+ )} +
+ ); +}; + +export default NotificationBell; diff --git a/src/components/NotificationBell/index.ts b/src/components/NotificationBell/index.ts new file mode 100644 index 0000000..ea2bff2 --- /dev/null +++ b/src/components/NotificationBell/index.ts @@ -0,0 +1,2 @@ +export { NotificationBell } from './NotificationBell'; +export { default } from './NotificationBell'; diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts index bc15520..bd2514b 100644 --- a/src/hooks/useInvitations.ts +++ b/src/hooks/useInvitations.ts @@ -30,6 +30,7 @@ export interface Invitation { mandateId: string; featureInstanceId?: string; roleIds: string[]; + targetUsername: string; email?: string; createdBy: string; createdAt: number; @@ -40,11 +41,13 @@ export interface Invitation { maxUses: number; currentUses: number; inviteUrl: string; + emailSent?: boolean; isExpired?: boolean; isUsedUp?: boolean; } export interface InvitationCreate { + targetUsername: string; email?: string; roleIds: string[]; featureInstanceId?: string; @@ -60,6 +63,7 @@ export interface InvitationValidation { featureInstanceId?: string; roleIds: string[]; roleLabels?: string[]; + targetUsername?: string; } diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..13ed028 --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,277 @@ +/** + * useNotifications Hook + * + * Hook for managing in-app notifications. + * Supports fetching, marking as read, and executing actions. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import api from '../api'; + +// Types +export interface NotificationAction { + actionId: string; + label: string; + style: 'primary' | 'danger' | 'default'; +} + +export interface UserNotification { + id: string; + userId: string; + type: 'invitation' | 'system' | 'workflow' | 'mention'; + status: 'unread' | 'read' | 'actioned' | 'dismissed'; + title: string; + message: string; + icon?: string; + referenceType?: string; + referenceId?: string; + actions?: NotificationAction[]; + actionTaken?: string; + actionResult?: string; + createdAt: number; + readAt?: number; + actionedAt?: number; + expiresAt?: number; +} + +export interface NotificationActionResult { + message: string; + action: string; + notificationId: string; +} + +/** + * Hook for managing notifications + */ +export function useNotifications() { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Polling interval ref + const pollingIntervalRef = useRef(null); + + /** + * Fetch all notifications for the current user + */ + const fetchNotifications = useCallback(async ( + options?: { status?: string; type?: string; limit?: number } + ): Promise => { + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + if (options?.status) params.append('status', options.status); + if (options?.type) params.append('type', options.type); + if (options?.limit) params.append('limit', options.limit.toString()); + + const queryString = params.toString(); + const url = `/api/notifications${queryString ? `?${queryString}` : ''}`; + + const response = await api.get(url); + const data = response.data as UserNotification[]; + setNotifications(data); + return data; + } catch (err: any) { + const errorMessage = err.response?.data?.detail || 'Fehler beim Laden der Benachrichtigungen'; + setError(errorMessage); + return []; + } finally { + setLoading(false); + } + }, []); + + /** + * Fetch unread count + */ + const fetchUnreadCount = useCallback(async (): Promise => { + try { + const response = await api.get('/api/notifications/unread-count'); + const count = response.data.count; + setUnreadCount(count); + return count; + } catch (err: any) { + console.error('Failed to fetch unread count:', err); + return 0; + } + }, []); + + /** + * Mark a notification as read + */ + const markAsRead = useCallback(async (notificationId: string): Promise => { + try { + await api.put(`/api/notifications/${notificationId}/read`); + + // Update local state + setNotifications(prev => + prev.map(n => + n.id === notificationId + ? { ...n, status: 'read' as const, readAt: Date.now() / 1000 } + : n + ) + ); + + // Update unread count + setUnreadCount(prev => Math.max(0, prev - 1)); + + return true; + } catch (err: any) { + console.error('Failed to mark notification as read:', err); + return false; + } + }, []); + + /** + * Mark all notifications as read + */ + const markAllAsRead = useCallback(async (): Promise => { + try { + await api.put('/api/notifications/mark-all-read'); + + // Update local state + setNotifications(prev => + prev.map(n => + n.status === 'unread' + ? { ...n, status: 'read' as const, readAt: Date.now() / 1000 } + : n + ) + ); + + setUnreadCount(0); + + return true; + } catch (err: any) { + console.error('Failed to mark all notifications as read:', err); + return false; + } + }, []); + + /** + * Execute an action on a notification + */ + const executeAction = useCallback(async ( + notificationId: string, + actionId: string + ): Promise => { + setLoading(true); + setError(null); + + try { + const response = await api.post(`/api/notifications/${notificationId}/action`, { + actionId + }); + + const result = response.data as NotificationActionResult; + + // Update local state + setNotifications(prev => + prev.map(n => + n.id === notificationId + ? { + ...n, + status: 'actioned' as const, + actionTaken: actionId, + actionResult: result.message, + actionedAt: Date.now() / 1000 + } + : n + ) + ); + + // Update unread count if it was unread + const notification = notifications.find(n => n.id === notificationId); + if (notification?.status === 'unread') { + setUnreadCount(prev => Math.max(0, prev - 1)); + } + + return result; + } catch (err: any) { + const errorMessage = err.response?.data?.detail || 'Fehler bei der Ausführung der Aktion'; + setError(errorMessage); + return null; + } finally { + setLoading(false); + } + }, [notifications]); + + /** + * Dismiss/delete a notification + */ + const dismissNotification = useCallback(async (notificationId: string): Promise => { + try { + await api.delete(`/api/notifications/${notificationId}`); + + // Update local state + const notification = notifications.find(n => n.id === notificationId); + + setNotifications(prev => prev.filter(n => n.id !== notificationId)); + + // Update unread count if it was unread + if (notification?.status === 'unread') { + setUnreadCount(prev => Math.max(0, prev - 1)); + } + + return true; + } catch (err: any) { + console.error('Failed to dismiss notification:', err); + return false; + } + }, [notifications]); + + /** + * Start polling for new notifications + */ + const startPolling = useCallback((intervalMs: number = 30000) => { + // Clear any existing interval + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + + // Fetch immediately + fetchUnreadCount(); + + // Set up polling + pollingIntervalRef.current = setInterval(() => { + fetchUnreadCount(); + }, intervalMs); + }, [fetchUnreadCount]); + + /** + * Stop polling + */ + const stopPolling = useCallback(() => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + }; + }, []); + + return { + notifications, + unreadCount, + loading, + error, + fetchNotifications, + fetchUnreadCount, + markAsRead, + markAllAsRead, + executeAction, + dismissNotification, + startPolling, + stopPolling + }; +} + +export default useNotifications; diff --git a/src/pages/InvitePage.tsx b/src/pages/InvitePage.tsx index ad568d4..1f9257a 100644 --- a/src/pages/InvitePage.tsx +++ b/src/pages/InvitePage.tsx @@ -1,14 +1,24 @@ /** * InvitePage * - * Public page for accepting invitations. + * Public page for accepting invitations via email link. * URL: /invite/:token * - * Flow: - * - Validates the invitation token - * - If user is authenticated: Accept invitation directly - * - If user is not authenticated: Store token and redirect to login/register - * The invitation will be accepted after successful authentication + * This page is primarily used for NEW users who receive an invitation email + * and need to register first. + * + * Flow for NEW users (via email link): + * 1. User opens email link → lands here + * 2. Token is validated and stored in localStorage + * 3. User clicks "Registrieren" → redirects to /register + * 4. After registration, user logs in + * 5. Login page checks localStorage, redirects back here + * 6. User clicks "Einladung annehmen" + * + * For EXISTING users: + * The in-app notification system handles invitations directly. + * When an invitation is created for an existing user, a notification + * appears in their notification bell with accept/decline buttons. */ import React, { useState, useEffect } from 'react'; @@ -50,8 +60,10 @@ export const InvitePage: React.FC = () => { // If invitation is valid but user is not authenticated, // store the token for later use after login/registration + // Use localStorage instead of sessionStorage to persist across tabs + // (e.g., when user opens password reset email in a new tab) if (result.valid && !isAuthenticated) { - sessionStorage.setItem(PENDING_INVITATION_KEY, token); + localStorage.setItem(PENDING_INVITATION_KEY, token); } }; @@ -69,7 +81,7 @@ export const InvitePage: React.FC = () => { if (result.success) { // Clear pending invitation token - sessionStorage.removeItem(PENDING_INVITATION_KEY); + localStorage.removeItem(PENDING_INVITATION_KEY); setSuccess(true); // Redirect to dashboard after 2 seconds setTimeout(() => { @@ -85,7 +97,7 @@ export const InvitePage: React.FC = () => { // Handle redirect to login (stores token first) const handleLoginRedirect = () => { if (token) { - sessionStorage.setItem(PENDING_INVITATION_KEY, token); + localStorage.setItem(PENDING_INVITATION_KEY, token); } navigate('/login', { state: { from: { pathname: `/invite/${token}` } } }); }; @@ -93,7 +105,7 @@ export const InvitePage: React.FC = () => { // Handle redirect to register (stores token first) const handleRegisterRedirect = () => { if (token) { - sessionStorage.setItem(PENDING_INVITATION_KEY, token); + localStorage.setItem(PENDING_INVITATION_KEY, token); } navigate('/register', { state: { from: { pathname: `/invite/${token}` }, pendingInvitation: true } }); }; @@ -157,6 +169,12 @@ export const InvitePage: React.FC = () => {
+ {validation.targetUsername && ( +
+ Eingeladen: + {validation.targetUsername} +
+ )} {validation.mandateName && (
Mandant: @@ -214,6 +232,12 @@ export const InvitePage: React.FC = () => {
+ {validation.targetUsername && ( +
+ Eingeladen: + {validation.targetUsername} +
+ )} {validation.mandateName && (
Mandant: @@ -229,7 +253,11 @@ export const InvitePage: React.FC = () => {
-

Bitte melden Sie sich an oder erstellen Sie ein Konto, um die Einladung anzunehmen.

+

+ {validation.targetUsername + ? `Bitte melden Sie sich als "${validation.targetUsername}" an, um die Einladung anzunehmen.` + : 'Bitte melden Sie sich an, um die Einladung anzunehmen.'} +

{error && ( diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index bc44f83..9756d96 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -21,7 +21,7 @@ function Login() { const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth(); // Check for pending invitation - const pendingInvitationToken = sessionStorage.getItem(PENDING_INVITATION_KEY); + const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const hasPendingInvitation = !!pendingInvitationToken; // Get the page the user was trying to visit diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 5b40aca..2602d10 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -32,7 +32,7 @@ function Register() { const [usernameHighlight, setUsernameHighlight] = useState(false); // Check for pending invitation - const pendingInvitationToken = sessionStorage.getItem(PENDING_INVITATION_KEY); + const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const hasPendingInvitation = !!pendingInvitationToken; // Set page title and generate CSRF token diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 06d8887..ae013e0 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -79,15 +79,31 @@ export const AdminInvitationsPage: React.FC = () => { // Table columns const columns = useMemo(() => [ + { + key: 'targetUsername', + label: 'Benutzername', + type: 'string' as const, + sortable: true, + filterable: true, + searchable: true, + width: 150, + }, { key: 'email', label: 'E-Mail', type: 'string' as const, sortable: true, filterable: true, - searchable: true, - width: 200, - render: (value: string) => value || '(beliebig)' + width: 180, + render: (value: string, row: Invitation) => { + const emailText = value || '-'; + const emailSent = (row as any).emailSent; + return ( + + {emailText} {emailSent && '✓'} + + ); + } }, { key: 'roleIds', @@ -413,7 +429,7 @@ export const AdminInvitationsPage: React.FC = () => {

- Teilen Sie diesen Link mit dem eingeladenen Benutzer: + Einladung für Benutzer {showUrlModal.targetUsername}:

{ {copySuccess ? ' Kopiert!' : ' Kopieren'}
+

+ Dieser Link kann nur von Benutzer {showUrlModal.targetUsername} verwendet werden. +

{showUrlModal.email && ( -

- Dieser Link kann nur von {showUrlModal.email} verwendet werden. +

+ {showUrlModal.emailSent + ? `✓ Email wurde an ${showUrlModal.email} gesendet` + : `Email-Adresse: ${showUrlModal.email} (nicht gesendet)`}

)}