994 lines
No EOL
35 KiB
TypeScript
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
|
|
};
|
|
}
|