371 lines
13 KiB
TypeScript
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;
|