import { useState, useEffect, useCallback } from 'react'; import { useApiRequest } from './useApi'; import { setUserDataCache, getUserDataCache, clearUserDataCache } from '../utils/userCache'; import api from '../api'; import { usePermissions, type UserPermissions } from './usePermissions'; import { fetchCurrentUser as fetchCurrentUserApi, logoutUser as logoutUserApi, fetchUsers as fetchUsersApi, fetchUserById as fetchUserByIdApi, createUser as createUserApi, updateUser as updateUserApi, deleteUser as deleteUserApi, sendPasswordLink as sendPasswordLinkApi, type User, type UserUpdateData, type AttributeDefinition, type PaginationParams } from '../api/userApi'; // Re-export types for backward compatibility export type { User, UserUpdateData, AttributeDefinition, PaginationParams }; // Current user hook export function useCurrentUser() { const [user, setUser] = useState(null); const { request, isLoading, error } = useApiRequest(); const fetchCurrentUser = async (retryCount = 0) => { try { // Check if we already have user data in sessionStorage cache const cachedUser = getUserDataCache(); if (cachedUser && cachedUser.username) { // Use cached user data - permissions are checked via RBAC API, not client-side // Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead setUser(cachedUser); console.log('βœ… Using cached user data from sessionStorage (persists during session):', { username: cachedUser.username, isSysAdmin: cachedUser.isSysAdmin, isPlatformAdmin: cachedUser.isPlatformAdmin }); return; } // JWT tokens are now stored in httpOnly cookies, so we fetch user data from API console.log('πŸͺ JWT tokens are in httpOnly cookies, fetching user data from API'); // Determine the correct endpoint based on authentication authority const authAuthority = sessionStorage.getItem('auth_authority'); let endpoint = '/api/local/me'; if (authAuthority === 'msft') { endpoint = '/api/msft/me'; } else if (authAuthority === 'google') { endpoint = '/api/google/me'; } console.log('πŸ” Fetching user data from API:', { endpoint, authAuthority, hasAuthCookies: document.cookie.includes('access_token') || document.cookie.includes('refresh_token') }); // Add a small delay to ensure cookies are properly set after authentication if (authAuthority === 'msft' || authAuthority === 'google') { console.log('⏳ Adding delay for OAuth authentication cookie propagation...'); await new Promise(resolve => setTimeout(resolve, 500)); } const data = await fetchCurrentUserApi(request, authAuthority || undefined); // Log response for debugging console.log('πŸ“¦ User data received from API:', { username: data?.username, isSysAdmin: data?.isSysAdmin, isPlatformAdmin: data?.isPlatformAdmin, allKeys: data ? Object.keys(data) : [] }); // Validate user data if (!data || !data.username) { console.error('❌ User data from API is invalid:', { username: data?.username, dataKeys: data ? Object.keys(data) : [] }); throw new Error('Invalid user data received from API'); } // Cache user data (permissions are checked via RBAC API) // Note: roleLabels is deprecated - use isSysAdmin/isPlatformAdmin flags for admin checks setUserDataCache(data); console.log('βœ… User data fetched from API and cached:', { username: data.username, isSysAdmin: data.isSysAdmin, isPlatformAdmin: data.isPlatformAdmin }); setUser(data); } catch (error: any) { console.error('❌ Failed to fetch user data:', error); // Display debug information if this is a Microsoft/Google auth failure const authAuthority = sessionStorage.getItem('auth_authority'); if (authAuthority === 'msft' || authAuthority === 'google') { console.log('πŸ”§ Current state:', { authAuthority, currentCookies: document.cookie || 'No cookies', hasAccessToken: document.cookie.includes('access_token'), hasRefreshToken: document.cookie.includes('refresh_token'), retryCount, error: error.message }); } // If authentication failed and we haven't retried yet, try again after a delay const isOAuth = authAuthority === 'msft' || authAuthority === 'google'; const maxRetries = isOAuth ? 2 : 0; // Only retry for OAuth if (retryCount < maxRetries && (error.message?.includes('Not authenticated') || error.message?.includes('Request aborted'))) { console.log(`πŸ”„ Retrying user data fetch (attempt ${retryCount + 1}/${maxRetries + 1}) in 2 seconds...`); setTimeout(() => { fetchCurrentUser(retryCount + 1); }, 2000); return; } // If all retries failed or this is not a retryable error setUser(null); clearUserDataCache(); // If authentication failed after all retries, clear auth data if (error.message?.includes('Not authenticated') || error.message?.includes('401')) { console.log('πŸ” Authentication failed after retries - clearing auth data'); // Clear auth authority sessionStorage.removeItem('auth_authority'); clearUserDataCache(); // Trigger a redirect to login by forcing a page reload setTimeout(() => { window.location.href = '/login'; }, 1000); } } }; const logout = async () => { if (!user) { throw new Error('No user to logout'); } try { // Determine the correct logout endpoint based on authentication authority // Note: logoutEndpoint is determined by logoutUserApi based on authenticationAuthority await logoutUserApi(request, user.authenticationAuthority); console.log('βœ… Logout API call completed, waiting for browser to process cookies...'); // 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 state after successful logout setUser(null); // Clear client-side auth hints; gateway session ended via API (cookies cleared by backend). console.log('🧹 Starting comprehensive cleanup...'); // Clear user data cache from sessionStorage clearUserDataCache(); // Clear auth authority from sessionStorage sessionStorage.removeItem('auth_authority'); // Optional: clear MSAL browser cache only (PowerOn JWT lives in httpOnly cookies + backend). // Do not call msal.logoutRedirect β€” that signs the user out of Microsoft globally. for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key && key.startsWith('msal.')) { localStorage.removeItem(key); } } // Clear cookies as backup (in case backend doesn't clear them properly) // Note: This only works for cookies that are accessible to JavaScript console.log('πŸͺ Checking cookies for cleanup...'); console.log('πŸͺ All cookies:', document.cookie); const cookies = document.cookie.split(";"); console.log('πŸͺ Cookie count:', cookies.length); cookies.forEach(function(c) { const cookieName = c.split("=")[0].trim(); console.log('πŸͺ Checking cookie:', cookieName); if (cookieName === 'auth_token' || cookieName === 'refresh_token' || cookieName.includes('token') || cookieName.includes('msal')) { console.log('πŸ—‘οΈ Clearing cookie:', cookieName); document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; } }); console.log('πŸͺ Cookies after cleanup attempt:', document.cookie); console.log('βœ… Cleanup completed'); // Redirect to login or home page console.log('πŸ”„ Redirecting to login page...'); window.location.href = '/login'; } catch (error) { console.error('Logout failed:', error); throw error; } }; useEffect(() => { // Try to load user from sessionStorage cache first for faster initial load const cachedUser = getUserDataCache(); if (cachedUser && cachedUser.username) { // Use cached user data - permissions are checked via RBAC API // Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead setUser(cachedUser); console.log('βœ… Using cached user data from sessionStorage on mount:', { username: cachedUser.username, isSysAdmin: cachedUser.isSysAdmin, isPlatformAdmin: cachedUser.isPlatformAdmin }); } // For OAuth authentication, wait a bit longer before fetching user data const authAuthority = sessionStorage.getItem('auth_authority'); const isOAuth = authAuthority === 'msft' || authAuthority === 'google'; if (isOAuth) { console.log('⏳ OAuth authentication detected, delaying user data fetch...'); // Wait for authentication cookies to be properly set const timer = setTimeout(() => { fetchCurrentUser(); }, 1000); return () => clearTimeout(timer); } else { // For local authentication, fetch immediately fetchCurrentUser(); } }, []); return { user, error, isLoading, refetch: fetchCurrentUser, logout }; } // Re-export AttributeOption for backward compatibility export interface AttributeOption { value: string | number; label: string; } // Organization users hook (list, update, delete) - following prompts/workflows pattern export function useOrgUsers() { const [users, setUsers] = useState([]); const [attributes, setAttributes] = useState([]); const [permissions, setPermissions] = useState(null); const [pagination, setPagination] = useState<{ currentPage: number; pageSize: number; totalItems: number; totalPages: number; } | null>(null); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); // Fetch attributes from backend const fetchAttributes = useCallback(async () => { try { const response = await api.get('/api/attributes/User'); // Extract attributes from response let attrs: AttributeDefinition[] = []; if (response.data?.attributes && Array.isArray(response.data.attributes)) { attrs = response.data.attributes; } else if (Array.isArray(response.data)) { attrs = response.data; } else if (response.data && typeof response.data === 'object') { const keys = Object.keys(response.data); for (const key of keys) { if (Array.isArray(response.data[key])) { attrs = response.data[key]; break; } } } setAttributes(attrs); return attrs; } catch (error: any) { // Don't log 429 errors as errors (they're rate limit warnings) if (error.response?.status === 429) { console.warn('Rate limit exceeded while fetching user attributes. Please wait.'); } else if (error.response?.status !== 401) { // Only log non-auth errors (401 is expected when not logged in) console.error('Error fetching attributes:', error); } setAttributes([]); return []; } }, []); // Fetch permissions from backend const fetchPermissions = useCallback(async () => { try { const perms = await checkPermission('DATA', 'User'); setPermissions(perms); return perms; } catch (error: any) { console.error('Error fetching permissions:', error); const defaultPerms: UserPermissions = { view: false, read: 'n', create: 'n', update: 'n', delete: 'n', }; setPermissions(defaultPerms); return defaultPerms; } }, [checkPermission]); const fetchUsers = useCallback(async (params?: PaginationParams) => { try { const requestParams: any = {}; // Build pagination object if provided if (params) { const paginationObj: any = {}; if (params.page !== undefined) paginationObj.page = params.page; if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); } } const data = await fetchUsersApi(request, params); // Handle paginated response if (data && typeof data === 'object' && 'items' in data) { const items = Array.isArray(data.items) ? data.items : []; setUsers(items); if (data.pagination) { setPagination(data.pagination); } } else { // Handle non-paginated response (backward compatibility) const items = Array.isArray(data) ? data : []; setUsers(items); setPagination(null); } } catch (error: any) { // Error is already handled by useApiRequest setUsers([]); setPagination(null); } }, [request]); // Optimistically remove a user from the local state const removeOptimistically = (userId: string) => { setUsers(prevUsers => prevUsers.filter(user => user.id !== userId)); }; // Optimistically update a user in the local state const updateOptimistically = (userId: string, updateData: Partial) => { setUsers(prevUsers => prevUsers.map(user => user.id === userId ? { ...user, ...updateData } : user ) ); }; // Fetch a single user by ID const fetchUserById = useCallback(async (userId: string): Promise => { return await fetchUserByIdApi(request, userId); }, [request]); // Generate edit fields from attributes dynamically const generateEditFieldsFromAttributes = useCallback((): Array<{ key: string; label: string; type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; editable?: boolean; required?: boolean; validator?: (value: any) => string | null; minRows?: number; maxRows?: number; options?: Array<{ value: string | number; label: string }>; optionsReference?: string; // For options that need to be fetched (e.g., "user.role") }> => { if (!attributes || attributes.length === 0) { return []; } const editableFields = attributes .filter(attr => { // Filter out non-editable fields based on readonly/editable flags if (attr.readonly === true || attr.editable === false) { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { // Map backend attribute type to form field type let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; let options: Array<{ value: string | number; label: string }> | undefined = undefined; let optionsReference: string | undefined = undefined; // Handle readonly fields if (attr.readonly === true || attr.editable === false) { fieldType = 'readonly'; } // Map backend types to form field types else if (attr.type === 'checkbox') { fieldType = 'boolean'; } else if (attr.type === 'email') { fieldType = 'email'; } else if (attr.type === 'date') { fieldType = 'date'; } else if (attr.type === 'select') { fieldType = 'enum'; // Handle options - can be array or string reference if (Array.isArray(attr.options)) { options = attr.options.map(opt => ({ value: opt.value, label: opt.label || String(opt.value) })); } else if (typeof attr.options === 'string') { // Options reference (e.g., "user.role", "auth.authority") optionsReference = attr.options; } } else if (attr.type === 'multiselect') { fieldType = 'multiselect'; // Handle options - can be array or string reference if (Array.isArray(attr.options)) { options = attr.options.map(opt => ({ value: opt.value, label: opt.label || String(opt.value) })); } else if (typeof attr.options === 'string') { // Options reference (e.g., "user.role", "auth.authority") optionsReference = attr.options; } } else if (attr.type === 'textarea') { fieldType = 'textarea'; } else if (attr.type === 'text') { // Check if it should be textarea based on name if (attr.name.toLowerCase().includes('description') || attr.name.toLowerCase().includes('content') || attr.name.toLowerCase().includes('note')) { fieldType = 'textarea'; } else { fieldType = 'string'; } } // Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union // If needed, they should be handled via type casting: (attr as any).type === 'boolean' // Define validators and required fields let required = attr.required === true; let validator: ((value: any) => string | null) | undefined = undefined; let minRows: number | undefined = undefined; let maxRows: number | undefined = undefined; // Email validation if (fieldType === 'email') { validator = (value: any) => { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) { return 'Email cannot be empty'; } if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { return 'Invalid email format'; } return null; }; } // Textarea settings else if (fieldType === 'textarea') { minRows = 4; maxRows = 8; if (attr.name.toLowerCase().includes('content')) { minRows = 6; maxRows = 12; } } // Multiselect validation else if (fieldType === 'multiselect' && required) { validator = (value: any[]) => { if (!value || !Array.isArray(value) || value.length === 0) { return `${attr.label} is required`; } return null; }; } return { key: attr.name, label: attr.label || attr.name, type: fieldType, editable: (attr as any).editable !== false && (attr as any).readonly !== true, required, validator, minRows, maxRows, options, optionsReference }; }); return editableFields; }, [attributes]); // Generate create fields from attributes dynamically // For users, we add a password field that's not in the backend attributes (since passwords are hashed) const generateCreateFieldsFromAttributes = useCallback((): Array<{ key: string; label: string; type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; required?: boolean; validator?: (value: any) => string | null; minRows?: number; maxRows?: number; options?: Array<{ value: string | number; label: string }>; optionsReference?: string; placeholder?: string; }> => { if (!attributes || attributes.length === 0) { return []; } const createFields = attributes .filter(attr => { // Filter out non-editable fields and auto-generated fields for create forms if (attr.readonly === true || attr.editable === false) { return false; } // Filter out ID fields and other auto-generated fields const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete', 'authenticationAuthority']; return !nonEditableFields.includes(attr.name); }) .map(attr => { // Map backend attribute type to form field type let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; let options: Array<{ value: string | number; label: string }> | undefined = undefined; let optionsReference: string | undefined = undefined; // Map backend types to form field types // Cast to string to handle all possible backend type values const attrType = attr.type as string; if (attrType === 'checkbox' || attrType === 'boolean') { fieldType = 'boolean'; } else if (attrType === 'email') { fieldType = 'email'; } else if (attrType === 'timestamp' || attrType === 'date' || attrType === 'time') { fieldType = 'date'; } else if (attrType === 'select' || attrType === 'enum') { fieldType = 'enum'; if (Array.isArray(attr.options)) { options = attr.options.map(opt => { return { value: opt.value, label: opt.label || String(opt.value) }; }); } else if (typeof attr.options === 'string') { optionsReference = attr.options; } } else if (attrType === 'multiselect') { fieldType = 'multiselect'; if (Array.isArray(attr.options)) { options = attr.options.map(opt => ({ value: opt.value, label: opt.label || String(opt.value) })); } else if (typeof attr.options === 'string') { optionsReference = attr.options; } } else if (attrType === 'textarea') { fieldType = 'textarea'; } // Determine if required and build validator const required = attr.required === true; let validator: ((value: any) => string | null) | undefined = undefined; // Email validation if (attr.type === 'email') { validator = (value: any) => { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) { return 'Email cannot be empty'; } if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { return 'Invalid email format'; } return null; }; } // Required string validation else if (required && fieldType === 'string') { validator = (value: any) => { if (!value || (typeof value === 'string' && value.trim() === '')) { return `${attr.label} is required`; } return null; }; } // Multiselect validation else if (fieldType === 'multiselect' && required) { validator = (value: any[]) => { if (!value || !Array.isArray(value) || value.length === 0) { return `${attr.label} is required`; } return null; }; } return { key: attr.name, label: attr.label || attr.name, type: fieldType, required, validator, options, optionsReference }; }); // Note: Password field removed - users are created without password // Admin can send password setup link after user creation using handleSendPasswordLink return createFields; }, [attributes]); // Ensure attributes are loaded - can be called by EditActionButton const ensureAttributesLoaded = useCallback(async () => { // Don't fetch attributes if user is not authenticated (prevents 401 errors) const currentUser = getUserDataCache(); if (!currentUser) { return []; } if (attributes && attributes.length > 0) { return attributes; } const fetchedAttributes = await fetchAttributes(); return fetchedAttributes; }, [attributes, fetchAttributes]); // Fetch attributes and permissions on mount (only if user is authenticated) useEffect(() => { const currentUser = getUserDataCache(); if (currentUser) { fetchAttributes(); fetchPermissions(); } }, [fetchAttributes, fetchPermissions]); // Initial fetch useEffect(() => { fetchUsers(); }, [fetchUsers]); return { data: users, loading, error, refetch: fetchUsers, removeOptimistically, updateOptimistically, attributes, permissions, pagination, fetchUserById, generateEditFieldsFromAttributes, generateCreateFieldsFromAttributes, ensureAttributesLoaded }; } // User operations hook export function useUserOperations() { const [deletingUsers, setDeletingUsers] = useState>(new Set()); const [editingUsers, setEditingUsers] = useState>(new Set()); const [sendingPasswordLink, setSendingPasswordLink] = useState>(new Set()); const [creatingUser, setCreatingUser] = useState(false); const { request, isLoading } = useApiRequest(); const [deleteError, setDeleteError] = useState(null); const [createError, setCreateError] = useState(null); const [updateError, setUpdateError] = useState(null); const [passwordLinkError, setPasswordLinkError] = useState(null); const handleUserDelete = async (userId: string) => { setDeleteError(null); setDeletingUsers(prev => new Set(prev).add(userId)); try { await deleteUserApi(request, userId); await new Promise(resolve => setTimeout(resolve, 300)); return true; } catch (error: any) { setDeleteError(error.message); return false; } finally { setDeletingUsers(prev => { const newSet = new Set(prev); newSet.delete(userId); return newSet; }); } }; const handleUserCreate = async (userData: Omit) => { setCreateError(null); setCreatingUser(true); try { // mandateId wird nicht mehr vom Client gesendet // Das Backend bestimmt den Kontext ΓΌber die instanceId im Request const newUser = await createUserApi(request, userData); return { success: true, userData: newUser }; } catch (error: any) { setCreateError(error.message); return { success: false, error: error.message }; } finally { setCreatingUser(false); } }; // Send password setup link to a user (admin function) const handleSendPasswordLink = async (userId: string) => { setPasswordLinkError(null); setSendingPasswordLink(prev => new Set(prev).add(userId)); try { // Get frontend URL from current window location const frontendUrl = `${window.location.protocol}//${window.location.host}`; const result = await sendPasswordLinkApi(request, userId, frontendUrl); return { success: true, message: result.message, email: result.email }; } catch (error: any) { const errorMessage = error.response?.data?.detail || error.message || 'Failed to send password link'; setPasswordLinkError(errorMessage); return { success: false, error: errorMessage }; } finally { setSendingPasswordLink(prev => { const newSet = new Set(prev); newSet.delete(userId); return newSet; }); } }; const handleUserUpdate = async (userId: string, updateData: UserUpdateData, _originalData?: any) => { setUpdateError(null); setEditingUsers(prev => new Set(prev).add(userId)); try { // mandateId wird nicht mehr vom Client gesendet const updatedUser = await updateUserApi(request, userId, updateData); return { success: true, userData: updatedUser }; } catch (error: any) { const errorMessage = error.response?.data?.message || error.message || 'Failed to update user'; const statusCode = error.response?.status; setUpdateError(errorMessage); return { success: false, error: errorMessage, statusCode, isPermissionError: statusCode === 403, isValidationError: statusCode === 400 }; } finally { setEditingUsers(prev => { const newSet = new Set(prev); newSet.delete(userId); return newSet; }); } }; // Generic inline update handler for FormGeneratorTable. // // The User PUT endpoint accepts PARTIAL payloads β€” only fields explicitly // present are applied; missing fields keep their stored value. We therefore // forward ONLY the changed cells. This avoids two classes of bugs: // 1. Stale snapshot: spreading ``existingRow`` onto the payload would // overwrite fields with whatever the client last loaded, even if the // backend has been updated since (e.g. by a parallel admin action). // 2. Missing-field default-flip: previously, any non-listed field (e.g. // ``isSysAdmin`` while toggling ``isPlatformAdmin``) was absent from // the merged payload and the Pydantic ``User`` body on the backend // filled it with ``False``, silently dropping the other privileged flag. // // ``existingRow`` is kept in the signature for forward-compat with table // hooks but is no longer consulted to build the payload. const handleInlineUpdate = async (userId: string, changes: Partial, _existingRow?: any) => { if (!changes || Object.keys(changes).length === 0) { throw new Error('No fields to update'); } const result = await handleUserUpdate(userId, changes); if (!result.success) { throw new Error(result.error || 'Failed to update'); } return result; }; return { deletingUsers, editingUsers, sendingPasswordLink, creatingUser, deleteError, createError, updateError, passwordLinkError, handleUserDelete, handleUserCreate, handleUserUpdate, handleInlineUpdate, handleSendPasswordLink, isLoading }; } // Individual user operations hook (for use when you don't need the full list) export function useUser() { const { request, isLoading, error } = useApiRequest(); const getUser = async (userId: string): Promise => { const user = await fetchUserByIdApi(request, userId); if (!user) { throw new Error('User not found'); } return user; }; const updateUser = async (userId: string, userData: UserUpdateData): Promise => { return await updateUserApi(request, userId, userData); }; const deleteUser = async (userId: string): Promise => { await deleteUserApi(request, userId); }; return { getUser, updateUser, deleteUser, isLoading, error }; }