mandate invitation and notification system

This commit is contained in:
ValueOn AG 2026-01-26 01:29:24 +01:00
parent 2b220fe816
commit 28af4cb068
11 changed files with 990 additions and 20 deletions

View file

@ -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;

View file

@ -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)}

View 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);
}

View 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;

View file

@ -0,0 +1,2 @@
export { NotificationBell } from './NotificationBell';
export { default } from './NotificationBell';

View file

@ -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;
}

View 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;

View file

@ -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 && (

View file

@ -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

View file

@ -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

View file

@ -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>
{showUrlModal.email && (
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
Dieser Link kann nur von <strong>{showUrlModal.email}</strong> verwendet werden.
Dieser Link kann nur von Benutzer <strong>{showUrlModal.targetUsername}</strong> verwendet werden.
</p>
{showUrlModal.email && (
<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)' }}>