frontend_nyla/src/pages/InvitePage.tsx

371 lines
13 KiB
TypeScript

/**
* InvitePage
*
* Public page for accepting invitations via email link.
* URL: /invite/:token
*
* 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';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useInvitations, type InvitationValidation } from '../hooks/useInvitations';
import api from '../api';
import { getUserDataCache } from '../utils/userCache';
import { FaCheckCircle, FaTimesCircle, FaSpinner, FaSignInAlt, FaUserPlus } from 'react-icons/fa';
import styles from './InvitePage.module.css';
// Key for storing pending invitation token
export const PENDING_INVITATION_KEY = 'pendingInvitationToken';
export const InvitePage: React.FC = () => {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { validateInvitation, acceptInvitation } = useInvitations();
// Check if user has an active session
const isAuthenticated = !!sessionStorage.getItem('auth_authority');
// State
const [validation, setValidation] = useState<InvitationValidation | null>(null);
const [validating, setValidating] = useState(true);
const [accepting, setAccepting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userMismatch, setUserMismatch] = useState(false);
const [userExists, setUserExists] = useState<boolean | null>(null);
// Validate token on mount
useEffect(() => {
const validate = async () => {
if (!token) {
setError('Kein Einladungs-Token angegeben');
setValidating(false);
return;
}
const result = await validateInvitation(token);
setValidation(result);
// 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) {
localStorage.setItem(PENDING_INVITATION_KEY, token);
// No targetUsername = new-user invitation (email only) -> only show "Neues Konto erstellen"
if (!result.targetUsername) {
setUserExists(false); // Treat as new user, show only register
} else {
// Check if the target username already has an account
try {
const resp = await api.get(`/api/local/available`, {
params: { username: result.targetUsername }
});
// available=true means username is free -> user does NOT exist
setUserExists(!resp.data.available);
} catch {
// On error, default to showing both options
setUserExists(null);
}
}
}
if (result.valid && isAuthenticated && result.targetUsername) {
const cachedUser = getUserDataCache();
if (cachedUser?.username && cachedUser.username.toLowerCase() !== result.targetUsername.toLowerCase()) {
localStorage.removeItem(PENDING_INVITATION_KEY);
setUserMismatch(true);
}
}
setValidating(false);
};
validate();
}, [token, validateInvitation, isAuthenticated]);
// Accept invitation (for authenticated users)
const handleAccept = async () => {
if (!token) return;
setAccepting(true);
setError(null);
const result = await acceptInvitation(token);
if (result.success) {
// Clear pending invitation token
localStorage.removeItem(PENDING_INVITATION_KEY);
setSuccess(true);
// Redirect to dashboard after 2 seconds
setTimeout(() => {
navigate('/');
}, 2000);
} else if (result.error?.includes('401') || result.error?.includes('Not authenticated')) {
// Session expired — clear auth and redirect to login
sessionStorage.removeItem('auth_authority');
handleLoginRedirect();
} else {
setError(result.error || 'Fehler beim Annehmen der Einladung');
}
setAccepting(false);
};
// Handle redirect to login (stores token first, passes invitation data for pre-fill)
const handleLoginRedirect = () => {
if (token) {
localStorage.setItem(PENDING_INVITATION_KEY, token);
}
navigate('/login', { state: {
from: { pathname: `/invite/${token}` },
invitationUsername: validation?.targetUsername || '',
} });
};
// Handle redirect to register (stores token first, passes invitation data for pre-fill)
const handleRegisterRedirect = () => {
if (token) {
localStorage.setItem(PENDING_INVITATION_KEY, token);
}
navigate('/register', { state: {
from: { pathname: `/invite/${token}` },
pendingInvitation: true,
invitationUsername: validation?.targetUsername || '',
invitationEmail: validation?.email || '',
} });
};
// Loading state
if (validating) {
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.loading}>
<FaSpinner className={styles.spinner} />
<p>Einladung wird überprüft...</p>
</div>
</div>
</div>
);
}
// Invalid invitation
if (!validation?.valid) {
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.errorState}>
<FaTimesCircle className={styles.errorIcon} />
<h1>Ungültige Einladung</h1>
<p>{validation?.reason || 'Diese Einladung ist nicht gültig.'}</p>
<Link to="/login" className={styles.primaryButton}>
Zur Anmeldung
</Link>
</div>
</div>
</div>
);
}
// Success state
if (success) {
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.successState}>
<FaCheckCircle className={styles.successIcon} />
<h1>Erfolgreich!</h1>
<p>Sie wurden erfolgreich zum Mandanten hinzugefügt.</p>
<p className={styles.redirectMessage}>Sie werden weitergeleitet...</p>
</div>
</div>
</div>
);
}
// Authenticated but invitation is for a different user
if (userMismatch && validation?.valid) {
const cachedUser = getUserDataCache();
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.errorState}>
<FaTimesCircle className={styles.errorIcon} />
<h1>Falsche Anmeldung</h1>
<p>
Diese Einladung ist für <strong>{validation.targetUsername}</strong> bestimmt.
Sie sind als <strong>{cachedUser?.username || 'anderer Benutzer'}</strong> angemeldet.
</p>
<p>Bitte melden Sie sich ab und mit dem richtigen Konto wieder an.</p>
<Link to="/" className={styles.primaryButton}>
Zum Dashboard
</Link>
</div>
</div>
</div>
);
}
// Already authenticated - show accept button
const isFeatureInvite = !!validation.featureInstanceId;
const introText = isFeatureInvite
? 'Sie wurden eingeladen, einem Mandanten und einem Feature beizutreten.'
: 'Sie wurden eingeladen, einem Mandanten beizutreten.';
const rolesLabel = isFeatureInvite ? 'Features mit zugewiesenen Rollen' : 'Zugewiesene Rollen';
const rolesValue = validation.featureInstanceName && validation.roleLabels?.length
? `${validation.featureInstanceName} (${validation.roleLabels.join(', ')})`
: validation.roleLabels?.join(', ') || '';
if (isAuthenticated) {
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.header}>
<h1>Einladung annehmen</h1>
<p>{introText}</p>
</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>
<span className={styles.infoValue}>{validation.mandateName}</span>
</div>
)}
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Status:</span>
<span className={styles.infoValue}>Angemeldet</span>
</div>
{rolesValue && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>{rolesLabel}:</span>
<span className={styles.infoValue}>{rolesValue}</span>
</div>
)}
</div>
{error && (
<div className={styles.errorMessage}>
<FaTimesCircle /> {error}
</div>
)}
<div className={styles.actions}>
<button
className={styles.primaryButton}
onClick={handleAccept}
disabled={accepting}
>
{accepting ? (
<>
<FaSpinner className={styles.spinner} /> Wird verarbeitet...
</>
) : (
'Einladung annehmen'
)}
</button>
<Link to="/" className={styles.secondaryButton}>
Abbrechen
</Link>
</div>
</div>
</div>
);
}
// Not authenticated - show create account / link to existing
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.header}>
<h1>Einladung annehmen</h1>
<p>{introText}</p>
</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>
<span className={styles.infoValue}>{validation.mandateName}</span>
</div>
)}
{rolesValue && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>{rolesLabel}:</span>
<span className={styles.infoValue}>{rolesValue}</span>
</div>
)}
</div>
<div className={styles.authPrompt}>
<p>
{userExists === true && validation.targetUsername
? `Sie haben bereits ein Konto (${validation.targetUsername}). Melden Sie sich an oder erstellen Sie ein neues Konto.`
: 'Erstellen Sie ein neues Konto mit Ihrem Benutzernamen oder verlinken Sie die Einladung mit Ihrem bestehenden Account.'}
</p>
</div>
{error && (
<div className={styles.errorMessage}>
<FaTimesCircle /> {error}
</div>
)}
<div className={styles.authActions}>
<button
className={styles.primaryButton}
onClick={handleRegisterRedirect}
>
<FaUserPlus /> Neues Konto erstellen
</button>
<div className={styles.divider}>
<span>oder</span>
</div>
<button
className={styles.secondaryButton}
onClick={handleLoginRedirect}
>
<FaSignInAlt /> Mit bestehendem Konto verlinken
</button>
</div>
<div className={styles.authInfo}>
<p>
Neues Konto: E-Mail wird vorausgefüllt, Benutzername legen Sie selbst fest. Bestehendes Konto: Die Einladung wird nach der Anmeldung automatisch an Ihr Konto verknüpft.
</p>
</div>
</div>
</div>
);
};
export default InvitePage;