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 {
|
.userSection {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notification Bell */
|
||||||
|
.notificationBell {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.userButton {
|
.userButton {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useCurrentUser } from '../../hooks/useUsers';
|
import { useCurrentUser } from '../../hooks/useUsers';
|
||||||
import { useMsal } from '@azure/msal-react';
|
import { useMsal } from '@azure/msal-react';
|
||||||
|
import { NotificationBell } from '../NotificationBell';
|
||||||
import styles from './UserSection.module.css';
|
import styles from './UserSection.module.css';
|
||||||
|
|
||||||
export const UserSection: React.FC = () => {
|
export const UserSection: React.FC = () => {
|
||||||
|
|
@ -49,6 +50,9 @@ export const UserSection: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.userSection}>
|
<div className={styles.userSection}>
|
||||||
|
{/* Notification Bell */}
|
||||||
|
<NotificationBell className={styles.notificationBell} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={styles.userButton}
|
className={styles.userButton}
|
||||||
onClick={() => setShowMenu(!showMenu)}
|
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;
|
mandateId: string;
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
roleIds: string[];
|
roleIds: string[];
|
||||||
|
targetUsername: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|
@ -40,11 +41,13 @@ export interface Invitation {
|
||||||
maxUses: number;
|
maxUses: number;
|
||||||
currentUses: number;
|
currentUses: number;
|
||||||
inviteUrl: string;
|
inviteUrl: string;
|
||||||
|
emailSent?: boolean;
|
||||||
isExpired?: boolean;
|
isExpired?: boolean;
|
||||||
isUsedUp?: boolean;
|
isUsedUp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvitationCreate {
|
export interface InvitationCreate {
|
||||||
|
targetUsername: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
roleIds: string[];
|
roleIds: string[];
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
|
|
@ -60,6 +63,7 @@ export interface InvitationValidation {
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
roleIds: string[];
|
roleIds: string[];
|
||||||
roleLabels?: 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
|
* InvitePage
|
||||||
*
|
*
|
||||||
* Public page for accepting invitations.
|
* Public page for accepting invitations via email link.
|
||||||
* URL: /invite/:token
|
* URL: /invite/:token
|
||||||
*
|
*
|
||||||
* Flow:
|
* This page is primarily used for NEW users who receive an invitation email
|
||||||
* - Validates the invitation token
|
* and need to register first.
|
||||||
* - If user is authenticated: Accept invitation directly
|
*
|
||||||
* - If user is not authenticated: Store token and redirect to login/register
|
* Flow for NEW users (via email link):
|
||||||
* The invitation will be accepted after successful authentication
|
* 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';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
@ -50,8 +60,10 @@ export const InvitePage: React.FC = () => {
|
||||||
|
|
||||||
// If invitation is valid but user is not authenticated,
|
// If invitation is valid but user is not authenticated,
|
||||||
// store the token for later use after login/registration
|
// 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) {
|
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) {
|
if (result.success) {
|
||||||
// Clear pending invitation token
|
// Clear pending invitation token
|
||||||
sessionStorage.removeItem(PENDING_INVITATION_KEY);
|
localStorage.removeItem(PENDING_INVITATION_KEY);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
// Redirect to dashboard after 2 seconds
|
// Redirect to dashboard after 2 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -85,7 +97,7 @@ export const InvitePage: React.FC = () => {
|
||||||
// Handle redirect to login (stores token first)
|
// Handle redirect to login (stores token first)
|
||||||
const handleLoginRedirect = () => {
|
const handleLoginRedirect = () => {
|
||||||
if (token) {
|
if (token) {
|
||||||
sessionStorage.setItem(PENDING_INVITATION_KEY, token);
|
localStorage.setItem(PENDING_INVITATION_KEY, token);
|
||||||
}
|
}
|
||||||
navigate('/login', { state: { from: { pathname: `/invite/${token}` } } });
|
navigate('/login', { state: { from: { pathname: `/invite/${token}` } } });
|
||||||
};
|
};
|
||||||
|
|
@ -93,7 +105,7 @@ export const InvitePage: React.FC = () => {
|
||||||
// Handle redirect to register (stores token first)
|
// Handle redirect to register (stores token first)
|
||||||
const handleRegisterRedirect = () => {
|
const handleRegisterRedirect = () => {
|
||||||
if (token) {
|
if (token) {
|
||||||
sessionStorage.setItem(PENDING_INVITATION_KEY, token);
|
localStorage.setItem(PENDING_INVITATION_KEY, token);
|
||||||
}
|
}
|
||||||
navigate('/register', { state: { from: { pathname: `/invite/${token}` }, pendingInvitation: true } });
|
navigate('/register', { state: { from: { pathname: `/invite/${token}` }, pendingInvitation: true } });
|
||||||
};
|
};
|
||||||
|
|
@ -157,6 +169,12 @@ export const InvitePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.inviteInfo}>
|
<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 && (
|
{validation.mandateName && (
|
||||||
<div className={styles.infoRow}>
|
<div className={styles.infoRow}>
|
||||||
<span className={styles.infoLabel}>Mandant:</span>
|
<span className={styles.infoLabel}>Mandant:</span>
|
||||||
|
|
@ -214,6 +232,12 @@ export const InvitePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.inviteInfo}>
|
<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 && (
|
{validation.mandateName && (
|
||||||
<div className={styles.infoRow}>
|
<div className={styles.infoRow}>
|
||||||
<span className={styles.infoLabel}>Mandant:</span>
|
<span className={styles.infoLabel}>Mandant:</span>
|
||||||
|
|
@ -229,7 +253,11 @@ export const InvitePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.authPrompt}>
|
<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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ function Login() {
|
||||||
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
||||||
|
|
||||||
// Check for pending invitation
|
// Check for pending invitation
|
||||||
const pendingInvitationToken = sessionStorage.getItem(PENDING_INVITATION_KEY);
|
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||||
const hasPendingInvitation = !!pendingInvitationToken;
|
const hasPendingInvitation = !!pendingInvitationToken;
|
||||||
|
|
||||||
// Get the page the user was trying to visit
|
// Get the page the user was trying to visit
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ function Register() {
|
||||||
const [usernameHighlight, setUsernameHighlight] = useState(false);
|
const [usernameHighlight, setUsernameHighlight] = useState(false);
|
||||||
|
|
||||||
// Check for pending invitation
|
// Check for pending invitation
|
||||||
const pendingInvitationToken = sessionStorage.getItem(PENDING_INVITATION_KEY);
|
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||||
const hasPendingInvitation = !!pendingInvitationToken;
|
const hasPendingInvitation = !!pendingInvitationToken;
|
||||||
|
|
||||||
// Set page title and generate CSRF token
|
// Set page title and generate CSRF token
|
||||||
|
|
|
||||||
|
|
@ -79,15 +79,31 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
|
|
||||||
// Table columns
|
// Table columns
|
||||||
const columns = useMemo(() => [
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'targetUsername',
|
||||||
|
label: 'Benutzername',
|
||||||
|
type: 'string' as const,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
label: 'E-Mail',
|
label: 'E-Mail',
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
width: 180,
|
||||||
width: 200,
|
render: (value: string, row: Invitation) => {
|
||||||
render: (value: string) => value || '(beliebig)'
|
const emailText = value || '-';
|
||||||
|
const emailSent = (row as any).emailSent;
|
||||||
|
return (
|
||||||
|
<span title={emailSent ? 'Email wurde gesendet' : 'Email nicht gesendet'}>
|
||||||
|
{emailText} {emailSent && '✓'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'roleIds',
|
key: 'roleIds',
|
||||||
|
|
@ -413,7 +429,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
<p style={{ marginBottom: '1rem', color: 'var(--text-secondary)' }}>
|
<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>
|
</p>
|
||||||
<div className={styles.urlBox}>
|
<div className={styles.urlBox}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -432,9 +448,14 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
{copySuccess ? ' Kopiert!' : ' Kopieren'}
|
{copySuccess ? ' Kopiert!' : ' Kopieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{showUrlModal.email && (
|
||||||
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSent ? 'var(--success-color)' : 'var(--text-secondary)' }}>
|
||||||
Dieser Link kann nur von <strong>{showUrlModal.email}</strong> verwendet werden.
|
{showUrlModal.emailSent
|
||||||
|
? `✓ Email wurde an ${showUrlModal.email} gesendet`
|
||||||
|
: `Email-Adresse: ${showUrlModal.email} (nicht gesendet)`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue