frontend_nyla/src/hooks/useUsers.ts

734 lines
No EOL
25 KiB
TypeScript

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,
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<User | null>(null);
const { request, isLoading, error } = useApiRequest<null, User>();
const fetchCurrentUser = async (retryCount = 0) => {
try {
// Check if we already have user data in sessionStorage cache
const cachedUser = getUserDataCache();
if (cachedUser) {
setUser(cachedUser);
console.log('✅ Using cached user data from sessionStorage (persists during session):', {
username: cachedUser.username,
privilege: cachedUser.privilege
});
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);
setUser(data);
// Cache user data in sessionStorage (cleared on tab close - more secure than localStorage)
setUserDataCache(data);
console.log('✅ User data fetched from API and cached in sessionStorage (secure):', {
username: data.username,
privilege: data.privilege
});
} 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 (msalInstance?: any) => {
if (!user) {
throw new Error('No user to logout');
}
try {
let logoutEndpoint = '/api/local/logout';
// Determine the correct logout endpoint based on authentication authority
if (user.authenticationAuthority === 'msft') {
logoutEndpoint = '/api/msft/logout';
} else if (user.authenticationAuthority === 'local') {
logoutEndpoint = '/api/local/logout';
}
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);
// CRITICAL: Clear all authentication data BEFORE any redirects
// This ensures cleanup happens even if MSAL redirect interrupts the process
console.log('🧹 Starting comprehensive cleanup...');
// 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 => {
console.log('🗑️ Removing token:', 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 => {
console.log('🗑️ Removing MSAL cache:', 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
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');
// Handle MSAL logout for Microsoft authentication
if (user.authenticationAuthority === 'msft' && msalInstance) {
try {
console.log('🔄 Starting MSAL logout redirect...');
await msalInstance.logoutRedirect({
onRedirectNavigate: () => {
console.log('🔄 MSAL redirect initiated - cleanup already completed');
return true;
}
});
return; // MSAL will handle the redirect
} catch (msalError) {
console.error('MSAL logout failed:', msalError);
// Continue with regular redirect if MSAL logout fails
}
}
// 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) {
setUser(cachedUser);
console.log('✅ Using cached user data from sessionStorage on mount (persists during session)');
}
// 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 | { [key: string]: string };
}
// Organization users hook (list, update, delete) - following prompts/workflows pattern
export function useOrgUsers() {
const [users, setUsers] = useState<User[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, User[]>();
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) {
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<User>) => {
setUsers(prevUsers =>
prevUsers.map(user =>
user.id === userId
? { ...user, ...updateData }
: user
)
);
};
// Fetch a single user by ID
const fetchUserById = useCallback(async (userId: string): Promise<User | null> => {
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', '_createdBy', '_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 => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} 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 => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} 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';
}
}
// Legacy support for old format
else if (attr.type === 'boolean') {
fieldType = 'boolean';
} else if (attr.type === 'enum' && attr.filterOptions) {
fieldType = 'enum';
options = attr.filterOptions.map(opt => ({ value: opt, label: opt }));
}
// 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: string) => {
if (required && (!value || value.trim() === '')) {
return 'Email cannot be empty';
}
if (value && !/^[^\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.editable !== false && attr.readonly !== true,
required,
validator,
minRows,
maxRows,
options,
optionsReference
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded - can be called by EditActionButton
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return {
data: users,
loading,
error,
refetch: fetchUsers,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchUserById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// User operations hook
export function useUserOperations() {
const [deletingUsers, setDeletingUsers] = useState<Set<string>>(new Set());
const [editingUsers, setEditingUsers] = useState<Set<string>>(new Set());
const [creatingUser, setCreatingUser] = useState(false);
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(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<User, 'id' | 'mandateId'> & { password: string }) => {
setCreateError(null);
setCreatingUser(true);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
mandateId: mandateId,
...userData
};
const newUser = await createUserApi(request, requestBody);
return { success: true, userData: newUser };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingUser(false);
}
};
const handleUserUpdate = async (userId: string, updateData: UserUpdateData, originalData?: any) => {
setUpdateError(null);
setEditingUsers(prev => new Set(prev).add(userId));
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
mandateId: mandateId,
...updateData
};
const updatedUser = await updateUserApi(request, userId, requestBody);
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;
});
}
};
return {
deletingUsers,
editingUsers,
creatingUser,
deleteError,
createError,
updateError,
handleUserDelete,
handleUserCreate,
handleUserUpdate,
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<User> => {
const user = await fetchUserByIdApi(request, userId);
if (!user) {
throw new Error('User not found');
}
return user;
};
const updateUser = async (userId: string, userData: UserUpdateData): Promise<User> => {
return await updateUserApi(request, userId, userData);
};
const deleteUser = async (userId: string): Promise<void> => {
await deleteUserApi(request, userId);
};
return {
getUser,
updateUser,
deleteUser,
isLoading,
error
};
}