mandate invitation and notification system
This commit is contained in:
parent
2b220fe816
commit
28af4cb068
11 changed files with 990 additions and 20 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={styles.userSection}>
|
||||
{/* Notification Bell */}
|
||||
<NotificationBell className={styles.notificationBell} />
|
||||
|
||||
<button
|
||||
className={styles.userButton}
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
|
|
|
|||
368
src/components/NotificationBell/NotificationBell.module.css
Normal file
368
src/components/NotificationBell/NotificationBell.module.css
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
/* NotificationBell Component Styles */
|
||||
|
||||
.notificationBell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Bell Button */
|
||||
.bellButton {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bellButton:hover {
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.bellIcon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background: var(--danger-color, #dc3545);
|
||||
border-radius: 10px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 290px;
|
||||
width: 360px;
|
||||
max-height: 480px;
|
||||
background: var(--card-bg, white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.markAllRead {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-color, #007bff);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.markAllRead:hover {
|
||||
background: var(--primary-light, rgba(0, 123, 255, 0.1));
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 32px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Notification Item */
|
||||
.notification {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.notification:hover {
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
.notification:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notification.unread {
|
||||
background: var(--primary-light, rgba(0, 123, 255, 0.05));
|
||||
}
|
||||
|
||||
.notification.unread::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
background: var(--success-light, rgba(40, 167, 69, 0.1));
|
||||
}
|
||||
|
||||
/* Success Overlay */
|
||||
.successOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: var(--success-light, rgba(40, 167, 69, 0.95));
|
||||
color: var(--success-color, #28a745);
|
||||
font-weight: 500;
|
||||
animation: fadeIn 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
color: var(--text-secondary, #6c757d);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.icon_invitation {
|
||||
background: var(--primary-light, rgba(0, 123, 255, 0.1));
|
||||
color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.icon_system {
|
||||
background: var(--info-light, rgba(23, 162, 184, 0.1));
|
||||
color: var(--info-color, #17a2b8);
|
||||
}
|
||||
|
||||
.icon_workflow {
|
||||
background: var(--warning-light, rgba(255, 193, 7, 0.1));
|
||||
color: var(--warning-color, #ffc107);
|
||||
}
|
||||
|
||||
.icon_mention {
|
||||
background: var(--purple-light, rgba(111, 66, 193, 0.1));
|
||||
color: var(--purple-color, #6f42c1);
|
||||
}
|
||||
|
||||
/* Notification Content */
|
||||
.notificationContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #333);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
|
||||
/* Truncate long messages */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #999);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.actionButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action_primary {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action_primary:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #0056b3);
|
||||
}
|
||||
|
||||
.action_danger {
|
||||
background: transparent;
|
||||
color: var(--danger-color, #dc3545);
|
||||
border: 1px solid var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
.action_danger:hover:not(:disabled) {
|
||||
background: var(--danger-light, rgba(220, 53, 69, 0.1));
|
||||
}
|
||||
|
||||
.action_default {
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.action_default:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary, #e9e9e9);
|
||||
}
|
||||
|
||||
/* Action Result */
|
||||
.actionResult {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
background: var(--success-light, rgba(40, 167, 69, 0.1));
|
||||
color: var(--success-color, #28a745);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Dismiss Button */
|
||||
.dismissButton {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted, #999);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.notification:hover .dismissButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dismissButton:hover {
|
||||
background: var(--danger-light, rgba(220, 53, 69, 0.1));
|
||||
color: var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #ddd);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted, #999);
|
||||
}
|
||||
257
src/components/NotificationBell/NotificationBell.tsx
Normal file
257
src/components/NotificationBell/NotificationBell.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* NotificationBell Component
|
||||
*
|
||||
* Displays a bell icon with unread count badge.
|
||||
* Clicking opens a dropdown with recent notifications.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
|
||||
import { useNotifications, UserNotification } from '../../hooks/useNotifications';
|
||||
import styles from './NotificationBell.module.css';
|
||||
|
||||
// Icon mapping for notification types
|
||||
const typeIcons: Record<string, React.ReactNode> = {
|
||||
invitation: <FaEnvelope />,
|
||||
system: <FaCog />,
|
||||
workflow: <FaCog />,
|
||||
mention: <FaExclamationTriangle />
|
||||
};
|
||||
|
||||
// Format timestamp to relative time
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = now - timestamp;
|
||||
|
||||
if (diff < 60) return 'Gerade eben';
|
||||
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min.`;
|
||||
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std.`;
|
||||
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
interface NotificationBellProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
error,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
executeAction,
|
||||
dismissNotification,
|
||||
startPolling,
|
||||
stopPolling
|
||||
} = useNotifications();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Start polling on mount
|
||||
useEffect(() => {
|
||||
startPolling(30000); // Poll every 30 seconds
|
||||
return () => stopPolling();
|
||||
}, [startPolling, stopPolling]);
|
||||
|
||||
// Fetch notifications when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchNotifications({ limit: 10 });
|
||||
}
|
||||
}, [isOpen, fetchNotifications]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle action button click
|
||||
const handleAction = useCallback(async (
|
||||
notification: UserNotification,
|
||||
actionId: string,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setActionLoading(`${notification.id}-${actionId}`);
|
||||
|
||||
const result = await executeAction(notification.id, actionId);
|
||||
|
||||
setActionLoading(null);
|
||||
|
||||
if (result) {
|
||||
setActionSuccess(notification.id);
|
||||
// Clear success state after animation
|
||||
setTimeout(() => {
|
||||
setActionSuccess(null);
|
||||
fetchNotifications({ limit: 10 });
|
||||
}, 2000);
|
||||
}
|
||||
}, [executeAction, fetchNotifications]);
|
||||
|
||||
// Handle dismiss
|
||||
const handleDismiss = useCallback(async (
|
||||
notification: UserNotification,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
await dismissNotification(notification.id);
|
||||
}, [dismissNotification]);
|
||||
|
||||
// Handle notification click (mark as read)
|
||||
const handleNotificationClick = useCallback(async (notification: UserNotification) => {
|
||||
if (notification.status === 'unread') {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
}, [markAsRead]);
|
||||
|
||||
// Filter out dismissed notifications
|
||||
const visibleNotifications = notifications.filter(n => n.status !== 'dismissed');
|
||||
|
||||
return (
|
||||
<div className={`${styles.notificationBell} ${className || ''}`} ref={dropdownRef}>
|
||||
{/* Bell Button */}
|
||||
<button
|
||||
className={styles.bellButton}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={`Benachrichtigungen ${unreadCount > 0 ? `(${unreadCount} ungelesen)` : ''}`}
|
||||
>
|
||||
<FaBell className={styles.bellIcon} />
|
||||
{unreadCount > 0 && (
|
||||
<span className={styles.badge}>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className={styles.dropdown}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<h3>Benachrichtigungen</h3>
|
||||
{visibleNotifications.some(n => n.status === 'unread') && (
|
||||
<button
|
||||
className={styles.markAllRead}
|
||||
onClick={() => markAllAsRead()}
|
||||
>
|
||||
Alle als gelesen markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={styles.content}>
|
||||
{loading && visibleNotifications.length === 0 && (
|
||||
<div className={styles.loading}>Lade...</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={styles.error}>{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && visibleNotifications.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<FaBell className={styles.emptyIcon} />
|
||||
<p>Keine Benachrichtigungen</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleNotifications.map(notification => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`
|
||||
${styles.notification}
|
||||
${notification.status === 'unread' ? styles.unread : ''}
|
||||
${actionSuccess === notification.id ? styles.success : ''}
|
||||
`}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
{/* Success overlay */}
|
||||
{actionSuccess === notification.id && (
|
||||
<div className={styles.successOverlay}>
|
||||
<FaCheckCircle />
|
||||
<span>{notification.actionResult || 'Erfolgreich'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`${styles.icon} ${styles[`icon_${notification.type}`]}`}>
|
||||
{typeIcons[notification.type] || <FaBell />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={styles.notificationContent}>
|
||||
<div className={styles.title}>{notification.title}</div>
|
||||
<div className={styles.message}>{notification.message}</div>
|
||||
<div className={styles.time}>{formatRelativeTime(notification.createdAt)}</div>
|
||||
|
||||
{/* Actions */}
|
||||
{notification.actions && notification.status !== 'actioned' && (
|
||||
<div className={styles.actions}>
|
||||
{notification.actions.map(action => (
|
||||
<button
|
||||
key={action.actionId}
|
||||
className={`${styles.actionButton} ${styles[`action_${action.style}`]}`}
|
||||
onClick={(e) => handleAction(notification, action.actionId, e)}
|
||||
disabled={actionLoading === `${notification.id}-${action.actionId}`}
|
||||
>
|
||||
{actionLoading === `${notification.id}-${action.actionId}` ? (
|
||||
'...'
|
||||
) : action.actionId === 'accept' ? (
|
||||
<><FaCheck /> {action.label}</>
|
||||
) : action.actionId === 'decline' ? (
|
||||
<><FaTimes /> {action.label}</>
|
||||
) : (
|
||||
action.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action result */}
|
||||
{notification.actionTaken && (
|
||||
<div className={styles.actionResult}>
|
||||
{notification.actionResult}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
{notification.status !== 'actioned' && (
|
||||
<button
|
||||
className={styles.dismissButton}
|
||||
onClick={(e) => handleDismiss(notification, e)}
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationBell;
|
||||
2
src/components/NotificationBell/index.ts
Normal file
2
src/components/NotificationBell/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { NotificationBell } from './NotificationBell';
|
||||
export { default } from './NotificationBell';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
277
src/hooks/useNotifications.ts
Normal file
277
src/hooks/useNotifications.ts
Normal file
|
|
@ -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<UserNotification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Polling interval ref
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch all notifications for the current user
|
||||
*/
|
||||
const fetchNotifications = useCallback(async (
|
||||
options?: { status?: string; type?: string; limit?: number }
|
||||
): Promise<UserNotification[]> => {
|
||||
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<number> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<NotificationActionResult | null> => {
|
||||
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<boolean> => {
|
||||
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;
|
||||
|
|
@ -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 = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.inviteInfo}>
|
||||
{validation.targetUsername && (
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Eingeladen:</span>
|
||||
<span className={styles.infoValue}><strong>{validation.targetUsername}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
{validation.mandateName && (
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Mandant:</span>
|
||||
|
|
@ -214,6 +232,12 @@ export const InvitePage: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.inviteInfo}>
|
||||
{validation.targetUsername && (
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Eingeladen:</span>
|
||||
<span className={styles.infoValue}><strong>{validation.targetUsername}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
{validation.mandateName && (
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Mandant:</span>
|
||||
|
|
@ -229,7 +253,11 @@ export const InvitePage: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.authPrompt}>
|
||||
<p>Bitte melden Sie sich an oder erstellen Sie ein Konto, um die Einladung anzunehmen.</p>
|
||||
<p>
|
||||
{validation.targetUsername
|
||||
? `Bitte melden Sie sich als "${validation.targetUsername}" an, um die Einladung anzunehmen.`
|
||||
: 'Bitte melden Sie sich an, um die Einladung anzunehmen.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span title={emailSent ? 'Email wurde gesendet' : 'Email nicht gesendet'}>
|
||||
{emailText} {emailSent && '✓'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'roleIds',
|
||||
|
|
@ -413,7 +429,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<p style={{ marginBottom: '1rem', color: 'var(--text-secondary)' }}>
|
||||
Teilen Sie diesen Link mit dem eingeladenen Benutzer:
|
||||
Einladung für Benutzer <strong>{showUrlModal.targetUsername}</strong>:
|
||||
</p>
|
||||
<div className={styles.urlBox}>
|
||||
<input
|
||||
|
|
@ -432,9 +448,14 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
{copySuccess ? ' Kopiert!' : ' Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
||||
Dieser Link kann nur von Benutzer <strong>{showUrlModal.targetUsername}</strong> verwendet werden.
|
||||
</p>
|
||||
{showUrlModal.email && (
|
||||
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
||||
Dieser Link kann nur von <strong>{showUrlModal.email}</strong> verwendet werden.
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSent ? 'var(--success-color)' : 'var(--text-secondary)' }}>
|
||||
{showUrlModal.emailSent
|
||||
? `✓ Email wurde an ${showUrlModal.email} gesendet`
|
||||
: `Email-Adresse: ${showUrlModal.email} (nicht gesendet)`}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue