frontend_nyla/src/hooks/useAuthentication.ts
2026-01-13 00:27:09 +01:00

760 lines
No EOL
23 KiB
TypeScript

import { useState } from 'react';
import { useMsal } from '@azure/msal-react';
import api from '../api';
import { useApiRequest } from './useApi';
import { getApiBaseUrl } from '../../config/config';
import { setUserDataCache, clearUserDataCache, type CachedUserData } from '../utils/userCache';
import {
loginApi,
fetchCurrentUserApi,
registerApi,
registerWithMsalApi,
checkUsernameAvailabilityApi,
logoutApi,
requestPasswordResetApi,
resetPasswordApi,
type LoginResponse,
type RegisterResponse,
type UsernameAvailabilityResponse,
type RegisterData,
type MsalRegisterData,
type PasswordResetRequestResponse,
type PasswordResetResponse
} from '../api/authApi';
// Regular authentication
export function useAuth() {
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const login = async (username: string, password: string): Promise<LoginResponse> => {
setIsLoading(true);
setError(null);
try {
const response = await loginApi({ username, password });
// Tokens are automatically set in httpOnly cookies by backend
if (response.type === 'local_auth_success') {
if (response.authenticationAuthority) {
// Use sessionStorage for non-sensitive routing hint (cleared when tab closes)
sessionStorage.setItem('auth_authority', response.authenticationAuthority);
}
// CRITICAL: Immediately fetch user data after successful login
try {
const userData = await fetchCurrentUserApi();
if (userData) {
// Cache user data in sessionStorage (cleared on tab close - more secure than localStorage)
setUserDataCache(userData as CachedUserData);
}
} catch (userError) {
console.error('Failed to fetch user data after login:', userError);
}
return response;
}
throw new Error('Login failed');
} catch (error: any) {
let errorMessage = 'An error occurred during login';
console.error('Login error:', error);
if (error.response) {
// Handle different error response formats
if (error.response.data?.detail) {
if (Array.isArray(error.response.data.detail)) {
errorMessage = error.response.data.detail.map((err: any) => err.msg || err).join(', ');
} else {
errorMessage = error.response.data.detail;
}
} else if (error.response.data?.message) {
errorMessage = error.response.data.message;
} else if (error.response.status === 500) {
errorMessage = 'Server error during login. Please try again.';
} else {
errorMessage = 'Invalid username or password';
}
} else if (error.request) {
errorMessage = 'No response received from server';
} else {
errorMessage = error.message;
}
setError(errorMessage);
throw error;
} finally {
setIsLoading(false);
}
};
return {
login,
error,
isLoading
};
}
// Microsoft Authentication
interface MsalAuthResponse {
accessToken: string;
tokenType: string;
user: {
username: string;
email: string;
fullName: string;
mandateId: number;
};
}
export function useMsalAuth() {
const [msalError, setMsalError] = useState<string | null>(null);
const [isMsalLoading, setIsMsalLoading] = useState(false);
const loginWithMsal = async (): Promise<MsalAuthResponse> => {
setIsMsalLoading(true);
setMsalError(null);
try {
return new Promise((resolve, reject) => {
const backendUrl = getApiBaseUrl();
// Open popup window
const popup = window.open(
`${backendUrl}/api/msft/login?state=login`,
'microsoft-login',
'width=600,height=700,left=100,top=100'
);
if (!popup) {
const errorMsg = 'Popup was blocked by browser. Please allow popups for this site and try again.';
console.error('Popup blocked:', errorMsg);
setMsalError(errorMsg);
setIsMsalLoading(false);
reject(new Error('Popup was blocked'));
return;
}
// Listen for messages from the popup
const messageListener = (event: MessageEvent) => {
// Filter out React DevTools messages
if (event.data?.source?.includes('react-devtools') ||
event.data?.source?.includes('devtools') ||
event.data?.hello === true) {
return; // Ignore React DevTools messages
}
// Verify the message origin for security (should match backend origin)
const apiUrl = new URL(backendUrl);
if (event.origin !== apiUrl.origin) {
console.warn('Message from unauthorized origin:', event.origin, 'Expected:', apiUrl.origin);
return;
}
if (event.data.type === 'msft_auth_success') {
console.log('Login successful!');
const tokenData = event.data.token_data;
// Store the token FIRST
localStorage.setItem('authToken', tokenData.tokenAccess);
// Set auth authority
if (event.data.authenticationAuthority) {
sessionStorage.setItem('auth_authority', event.data.authenticationAuthority);
} else {
sessionStorage.setItem('auth_authority', 'msft');
}
// Configure axios to use the token
api.defaults.headers.common['Authorization'] = `Bearer ${tokenData.tokenAccess}`;
// NOW fetch user data with proper authentication
setTimeout(async () => {
try {
const userData = await fetchCurrentUserApi('msft');
if (userData) {
setUserDataCache(userData as CachedUserData);
}
} catch (userError) {
console.error('Failed to fetch user data after Microsoft login:', userError);
}
}, 100); // Reduced timeout since we're not waiting for cookies
// Clean up
window.removeEventListener('message', messageListener);
popup.close();
setIsMsalLoading(false);
// Resolve with the response data
resolve(event.data);
} else if (event.data.type === 'msft_connection_error') {
console.error('Login failed:', event.data.error);
// Handle error
window.removeEventListener('message', messageListener);
popup.close();
setIsMsalLoading(false);
setMsalError(event.data.error || 'Microsoft authentication failed');
reject(new Error(event.data.error || 'Microsoft authentication failed'));
}
};
// Add message listener
window.addEventListener('message', messageListener);
// Optional: Check if popup was closed without completing auth
const checkPopup = setInterval(() => {
if (popup.closed) {
clearInterval(checkPopup);
window.removeEventListener('message', messageListener);
setIsMsalLoading(false);
setMsalError('Authentication was cancelled - popup was closed before completing login');
reject(new Error('Authentication was cancelled'));
}
}, 1000);
});
} catch (error: any) {
console.error('MSAL authentication error:', error);
setMsalError(error.message || 'Microsoft authentication failed');
setIsMsalLoading(false);
throw error;
}
};
return {
loginWithMsal,
error: msalError,
isLoading: isMsalLoading
};
}
// Registration
export function useRegister() {
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const register = async (userData: RegisterData): Promise<RegisterResponse> => {
setIsLoading(true);
setError(null);
try {
return await registerApi(userData);
} catch (error: any) {
let errorMessage = 'An error occurred during registration';
if (error.response) {
// Handle validation errors from FastAPI
if (error.response.data?.detail) {
if (Array.isArray(error.response.data.detail)) {
// Handle FastAPI validation errors array
errorMessage = error.response.data.detail.map((err: any) => err.msg).join(', ');
} else {
errorMessage = error.response.data.detail;
}
} else {
errorMessage = 'Registration failed';
}
} else if (error.request) {
errorMessage = 'No response received from server';
} else {
errorMessage = error.message;
}
setError(errorMessage);
throw error;
} finally {
setIsLoading(false);
}
};
return {
register,
error,
isLoading
};
}
// Google Authentication
interface GoogleAuthResponse {
accessToken: string;
tokenType: string;
user: {
username: string;
email: string;
fullName: string;
mandateId: number;
};
}
export function useGoogleAuth() {
const [googleError, setGoogleError] = useState<string | null>(null);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const loginWithGoogle = async (): Promise<GoogleAuthResponse> => {
setIsGoogleLoading(true);
setGoogleError(null);
try {
return new Promise((resolve, reject) => {
const backendUrl = getApiBaseUrl();
// Open popup window
const popup = window.open(
`${backendUrl}/api/google/login?state=login`,
'google-login',
'width=600,height=700,left=100,top=100'
);
if (!popup) {
const errorMsg = 'Popup was blocked by browser. Please allow popups for this site and try again.';
console.error('Popup blocked:', errorMsg);
setGoogleError(errorMsg);
setIsGoogleLoading(false);
reject(new Error('Popup was blocked'));
return;
}
// Listen for messages from the popup
const messageListener = (event: MessageEvent) => {
// Filter out React DevTools messages
if (event.data?.source?.includes('react-devtools') ||
event.data?.source?.includes('devtools') ||
event.data?.hello === true) {
return; // Ignore React DevTools messages
}
// Verify the message origin for security (should match backend origin)
const apiUrl = new URL(backendUrl);
if (event.origin !== apiUrl.origin) {
console.warn('Message from unauthorized origin:', event.origin, 'Expected:', apiUrl.origin);
return;
}
if (event.data.type === 'google_auth_success') {
console.log('Login successful!');
const tokenData = event.data.token_data;
// Store the token FIRST
localStorage.setItem('authToken', tokenData.tokenAccess);
// Set auth authority
if (event.data.authenticationAuthority) {
sessionStorage.setItem('auth_authority', event.data.authenticationAuthority);
} else {
sessionStorage.setItem('auth_authority', 'google');
}
// Configure axios to use the token
api.defaults.headers.common['Authorization'] = `Bearer ${tokenData.tokenAccess}`;
// NOW fetch user data with proper authentication
setTimeout(async () => {
try {
const userData = await fetchCurrentUserApi('google');
if (userData) {
setUserDataCache(userData as CachedUserData);
}
} catch (userError) {
console.error('Failed to fetch user data after Google login:', userError);
}
}, 100); // Reduced timeout since we're not waiting for cookies
// Clean up
window.removeEventListener('message', messageListener);
popup.close();
setIsGoogleLoading(false);
// Resolve with the response data
resolve(event.data);
} else if (event.data.type === 'google_connection_error') {
console.error('Login failed:', event.data.error);
// Handle error
window.removeEventListener('message', messageListener);
popup.close();
setIsGoogleLoading(false);
setGoogleError(event.data.error || 'Google authentication failed');
reject(new Error(event.data.error || 'Google authentication failed'));
}
};
// Add message listener
window.addEventListener('message', messageListener);
// Optional: Check if popup was closed without completing auth
const checkPopup = setInterval(() => {
if (popup.closed) {
clearInterval(checkPopup);
window.removeEventListener('message', messageListener);
setIsGoogleLoading(false);
setGoogleError('Authentication was cancelled - popup was closed before completing login');
reject(new Error('Authentication was cancelled'));
}
}, 1000);
});
} catch (error: any) {
console.error('Google authentication error:', error);
setGoogleError(error.message || 'Google authentication failed');
setIsGoogleLoading(false);
throw error;
}
};
return {
loginWithGoogle,
error: googleError,
isLoading: isGoogleLoading
};
}
// Microsoft Registration
export function useMsalRegister() {
const { instance, accounts } = useMsal();
const { request, isLoading, error } = useApiRequest<MsalRegisterData, any>();
const registerWithMsal = async (): Promise<RegisterResponse> => {
try {
if (!accounts || accounts.length === 0) {
// If not signed in with Microsoft, sign in first
await instance.loginPopup({
scopes: ['user.read']
});
}
// Get the current account
const currentAccount = instance.getAllAccounts()[0];
if (!currentAccount) {
throw new Error('No Microsoft account found');
}
// Prepare user data from Microsoft account
const userData: MsalRegisterData = {
username: currentAccount.username,
email: currentAccount.username,
fullName: currentAccount.name || currentAccount.username,
language: 'de'
};
// Register the user through our backend
return await registerWithMsalApi(request, userData);
} catch (error: any) {
throw error;
}
};
return {
registerWithMsal,
error,
isLoading
};
}
// Username availability check
export function useUsernameAvailability() {
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const checkAvailability = async (
username: string,
authenticationAuthority: string = 'local'
): Promise<UsernameAvailabilityResponse> => {
setIsChecking(true);
setError(null);
try {
return await checkUsernameAvailabilityApi(username, authenticationAuthority);
} catch (error: any) {
let errorMessage = 'Failed to check username availability';
if (error.response) {
errorMessage = error.response.data?.detail || errorMessage;
}
setError(errorMessage);
throw error;
} finally {
setIsChecking(false);
}
};
return {
checkAvailability,
isChecking,
error
};
}
// Password reset request (by username)
export function usePasswordResetRequest() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const requestReset = async (username: string): Promise<PasswordResetRequestResponse> => {
setIsLoading(true);
setError(null);
setSuccess(false);
try {
const response = await requestPasswordResetApi(username);
setSuccess(true);
return response;
} catch (error: any) {
// For security, we don't reveal if the username exists or not
// So we still show success even on error
setSuccess(true);
return { success: true, message: 'If a user with this username exists, a reset link has been sent to their email.' };
} finally {
setIsLoading(false);
}
};
return {
requestReset,
isLoading,
error,
success
};
}
// Password reset (set new password with token)
export function usePasswordReset() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const resetPassword = async (token: string, password: string): Promise<PasswordResetResponse> => {
setIsLoading(true);
setError(null);
setSuccess(false);
try {
const response = await resetPasswordApi(token, password);
setSuccess(true);
return response;
} catch (error: any) {
let errorMessage = 'Passwort-Zurücksetzung fehlgeschlagen';
if (error.response) {
if (error.response.data?.detail) {
if (Array.isArray(error.response.data.detail)) {
errorMessage = error.response.data.detail.map((err: any) => err.msg || err).join(', ');
} else {
errorMessage = error.response.data.detail;
}
}
} else if (error.message) {
errorMessage = error.message;
}
setError(errorMessage);
throw error;
} finally {
setIsLoading(false);
}
};
return {
resetPassword,
isLoading,
error,
success
};
}
// Logout function
export function useLogout() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const logout = async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
// Call logout endpoint to clear JWT tokens on server
await logoutApi();
// CRITICAL: Wait for browser to process Set-Cookie headers from logout response
// This gives the browser time to clear httpOnly cookies before redirect
await new Promise(resolve => setTimeout(resolve, 1000));
// Clear user data cache from sessionStorage
clearUserDataCache();
// Clear auth authority from sessionStorage
sessionStorage.removeItem('auth_authority');
// Clear MSAL cache tokens from localStorage
// MSAL stores tokens with keys starting with 'msal.'
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (
key.startsWith('msal.') ||
key === 'auth_token' ||
key === 'refresh_token' ||
key.includes('token') ||
key.includes('auth') ||
key.includes('msal')
)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key);
});
// Clear ALL MSAL cache data (including account keys, token keys, version)
const msalKeysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('msal.')) {
msalKeysToRemove.push(key);
}
}
msalKeysToRemove.forEach(key => {
localStorage.removeItem(key);
});
// Clear sessionStorage as well (CSRF tokens, etc.)
sessionStorage.clear();
// Clear cookies as backup (in case backend doesn't clear them properly)
// Note: This only works for cookies that are accessible to JavaScript
const cookies = document.cookie.split(";");
cookies.forEach(function(c) {
const cookieName = c.split("=")[0].trim();
if (cookieName === 'auth_token' || cookieName === 'refresh_token' || cookieName.includes('token') || cookieName.includes('msal')) {
document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
}
});
// Redirect to login page
window.location.href = '/login?logout=true';
} catch (error: any) {
let errorMessage = 'Logout failed';
if (error.response) {
errorMessage = error.response.data?.detail || errorMessage;
}
setError(errorMessage);
// Even if logout fails on server, clear local data and redirect
clearUserDataCache();
sessionStorage.removeItem('auth_authority');
// Clear MSAL cache tokens from localStorage
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (
key.startsWith('msal.') ||
key === 'auth_token' ||
key === 'refresh_token' ||
key.includes('token') ||
key.includes('auth') ||
key.includes('msal')
)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key);
});
// Clear ALL MSAL cache data (including account keys, token keys, version)
const msalKeysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('msal.')) {
msalKeysToRemove.push(key);
}
}
msalKeysToRemove.forEach(key => {
localStorage.removeItem(key);
});
// Clear sessionStorage as well
sessionStorage.clear();
// Clear cookies as backup (in case backend doesn't clear them properly)
document.cookie.split(";").forEach(function(c) {
const cookieName = c.split("=")[0].trim();
if (cookieName === 'auth_token' || cookieName === 'refresh_token' || cookieName.includes('token') || cookieName.includes('msal')) {
document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
}
});
window.location.href = '/login?logout=true';
} finally {
setIsLoading(false);
}
};
return {
logout,
isLoading,
error
};
}
// Get current user
export function useCurrentUser() {
const [user, setUser] = useState<any>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getCurrentUser = async (): Promise<any> => {
setIsLoading(true);
setError(null);
try {
// Determine the correct endpoint based on authentication authority
const authAuthority = sessionStorage.getItem('auth_authority') || undefined;
const userData = await fetchCurrentUserApi(authAuthority);
setUser(userData);
// Cache user data in sessionStorage (cleared on tab close - more secure than localStorage)
setUserDataCache(userData as CachedUserData);
return userData;
} catch (error: any) {
let errorMessage = 'Failed to get current user';
if (error.response) {
errorMessage = error.response.data?.detail || errorMessage;
}
setError(errorMessage);
throw error;
} finally {
setIsLoading(false);
}
};
return {
user,
getCurrentUser,
isLoading,
error
};
}