300 lines
9.7 KiB
TypeScript
300 lines
9.7 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 { 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 auth token (simplified check)
|
|
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);
|
|
|
|
// 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);
|
|
setValidating(false);
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|
|
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 {
|
|
setError(result.error || 'Fehler beim Annehmen der Einladung');
|
|
}
|
|
|
|
setAccepting(false);
|
|
};
|
|
|
|
// Handle redirect to login (stores token first)
|
|
const handleLoginRedirect = () => {
|
|
if (token) {
|
|
localStorage.setItem(PENDING_INVITATION_KEY, token);
|
|
}
|
|
navigate('/login', { state: { from: { pathname: `/invite/${token}` } } });
|
|
};
|
|
|
|
// Handle redirect to register (stores token first)
|
|
const handleRegisterRedirect = () => {
|
|
if (token) {
|
|
localStorage.setItem(PENDING_INVITATION_KEY, token);
|
|
}
|
|
navigate('/register', { state: { from: { pathname: `/invite/${token}` }, pendingInvitation: true } });
|
|
};
|
|
|
|
// 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>
|
|
);
|
|
}
|
|
|
|
// Already authenticated - show accept button
|
|
if (isAuthenticated) {
|
|
return (
|
|
<div className={styles.container}>
|
|
<div className={styles.card}>
|
|
<div className={styles.header}>
|
|
<h1>Einladung annehmen</h1>
|
|
<p>Sie wurden eingeladen, einem Mandanten beizutreten.</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>
|
|
{validation.roleLabels && validation.roleLabels.length > 0 && (
|
|
<div className={styles.infoRow}>
|
|
<span className={styles.infoLabel}>Zugewiesene Rollen:</span>
|
|
<span className={styles.infoValue}>{validation.roleLabels.join(', ')}</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 login/register options (NO inline registration form)
|
|
return (
|
|
<div className={styles.container}>
|
|
<div className={styles.card}>
|
|
<div className={styles.header}>
|
|
<h1>Einladung annehmen</h1>
|
|
<p>Sie wurden eingeladen, einem Mandanten beizutreten.</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>
|
|
)}
|
|
{validation.roleLabels && validation.roleLabels.length > 0 && (
|
|
<div className={styles.infoRow}>
|
|
<span className={styles.infoLabel}>Zugewiesene Rollen:</span>
|
|
<span className={styles.infoValue}>{validation.roleLabels.join(', ')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.authPrompt}>
|
|
<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 && (
|
|
<div className={styles.errorMessage}>
|
|
<FaTimesCircle /> {error}
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.authActions}>
|
|
<button
|
|
className={styles.primaryButton}
|
|
onClick={handleLoginRedirect}
|
|
>
|
|
<FaSignInAlt /> Anmelden
|
|
</button>
|
|
|
|
<div className={styles.divider}>
|
|
<span>oder</span>
|
|
</div>
|
|
|
|
<button
|
|
className={styles.secondaryButton}
|
|
onClick={handleRegisterRedirect}
|
|
>
|
|
<FaUserPlus /> Neues Konto erstellen
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.authInfo}>
|
|
<p>
|
|
Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen.
|
|
Die Einladung wird automatisch nach der Anmeldung akzeptiert.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default InvitePage;
|