frontend_nyla/src/hooks/useUsers.ts
2026-01-13 23:16:37 +01:00

994 lines
No EOL
35 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,
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<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 && cachedUser.username) {
// Check if cached user has roleLabels - if empty, refetch from API
const hasRoleLabels = Array.isArray(cachedUser.roleLabels) && cachedUser.roleLabels.length > 0;
if (!hasRoleLabels) {
console.warn('⚠️ Cached user data has no roleLabels, refetching from API:', {
username: cachedUser.username,
roleLabels: cachedUser.roleLabels
});
// Clear cache and continue to fetch from API
clearUserDataCache();
} else {
// Use cached user data - permissions are checked via RBAC API, not client-side
setUser(cachedUser);
console.log('✅ Using cached user data from sessionStorage (persists during session):', {
username: cachedUser.username,
roleLabels: cachedUser.roleLabels
});
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 full response for debugging
console.log('📦 User data received from API:', {
username: data?.username,
roleLabels: data?.roleLabels,
hasRoleLabels: !!data?.roleLabels,
roleLabelsLength: Array.isArray(data?.roleLabels) ? data.roleLabels.length : 0,
roleLabelsContent: Array.isArray(data?.roleLabels) ? data.roleLabels : 'not an array',
allKeys: data ? Object.keys(data) : [],
fullData: JSON.stringify(data, null, 2)
});
// Always cache user data - permissions are checked via RBAC API, not client-side
// roleLabels are optional metadata for display/logging purposes
if (!data || !data.username) {
console.error('❌ User data from API is invalid:', {
username: data?.username,
dataKeys: data ? Object.keys(data) : [],
fullResponse: data
});
throw new Error('Invalid user data received from API');
}
// Check if API returned roleLabels - if not, log warning but still cache
const hasRoleLabels = Array.isArray(data.roleLabels) && data.roleLabels.length > 0;
if (!hasRoleLabels) {
console.warn('⚠️ User data from API has no roleLabels - this may cause RBAC issues:', {
username: data.username,
roleLabels: data.roleLabels,
allKeys: Object.keys(data),
fullResponse: JSON.stringify(data, null, 2)
});
// Still cache it, but log the issue - backend RBAC should handle permissions
// However, if backend expects roleLabels, this will cause problems
}
// Cache user data (permissions are checked via RBAC API)
setUserDataCache(data);
console.log('✅ User data fetched from API and cached in sessionStorage (secure):', {
username: data.username,
roleLabels: data.roleLabels,
roleLabelsLength: Array.isArray(data.roleLabels) ? data.roleLabels.length : 0,
hasRoleLabels
});
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 (msalInstance?: any) => {
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);
// 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 && cachedUser.username) {
// Check if cached user has roleLabels - if empty, refetch from API
const hasRoleLabels = Array.isArray(cachedUser.roleLabels) && cachedUser.roleLabels.length > 0;
if (!hasRoleLabels) {
console.warn('⚠️ Cached user data has no roleLabels, refetching from API:', {
username: cachedUser.username,
roleLabels: cachedUser.roleLabels
});
// Clear cache and refetch
clearUserDataCache();
fetchCurrentUser();
return;
}
// Use cached user data - permissions are checked via RBAC API
setUser(cachedUser);
console.log('✅ Using cached user data from sessionStorage on mount (persists during session):', {
username: cachedUser.username,
roleLabels: cachedUser.roleLabels
});
}
// 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) {
// 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<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';
}
}
// 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', '_createdBy', '_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 => {
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') {
optionsReference = attr.options;
}
} else if (attrType === 'multiselect') {
fieldType = 'multiselect';
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') {
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<Set<string>>(new Set());
const [editingUsers, setEditingUsers] = useState<Set<string>>(new Set());
const [sendingPasswordLink, setSendingPasswordLink] = 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 [passwordLinkError, setPasswordLinkError] = 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'>) => {
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);
}
};
// 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 {
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;
});
}
};
// Generic inline update handler for FormGeneratorTable
// Must merge changes with existing row data because backend requires full object
// The existingRow parameter is passed from FormGeneratorTable which has access to row data
const handleInlineUpdate = async (userId: string, changes: Partial<UserUpdateData>, existingRow?: any) => {
if (!existingRow) {
throw new Error(`Existing row data required for inline update`);
}
// Merge changes with existing row data (backend requires full object with required fields)
const mergedData: UserUpdateData = {
username: existingRow.username,
email: existingRow.email,
enabled: existingRow.enabled,
roleLabels: existingRow.roleLabels,
...changes
};
const result = await handleUserUpdate(userId, mergedData);
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<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
};
}