Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 7 additions & 17 deletions frontend/src/components/admin/UserFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,14 @@ const UserFormModal: React.FC<UserFormModalProps> = ({
permissionComponents.forEach(component => {
setPermissionChange(component.id, 'write');
});
} else {
// Reset permissions to their unselected state
permissionComponents.forEach(component => {
setPermissionChange(component.id, null);
});
}
}, [isAdmin]);
// Note: permissionComponents is memoized with useMemo in parent, setPermissionChange is useCallback - both are stable
}, [isAdmin, permissionComponents, setPermissionChange]);

useEffect(() => {
if (!isOpen) {
Expand Down Expand Up @@ -208,22 +214,6 @@ const UserFormModal: React.FC<UserFormModalProps> = ({
</div>

<div className="custom-scrollbar flex-1 overflow-y-auto px-6 py-5">
{formError && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-5 flex items-center gap-2.5 rounded-lg border px-4 py-3 text-sm"
style={{
color: isDark ? '#f87171' : '#ef4444',
borderColor: isDark ? 'rgba(248, 113, 113, 0.2)' : 'rgba(239, 68, 68, 0.1)',
background: isDark ? 'rgba(239, 68, 68, 0.1)' : 'rgba(254, 226, 226, 0.5)',
}}
>
<FiAlertCircle size={18} />
<span>{formError}</span>
</motion.div>
)}

<form onSubmit={handleSubmit} className="space-y-5">
{/* Username field */}
<div>
Expand Down
120 changes: 94 additions & 26 deletions frontend/src/components/admin/UserManagement.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import axios from 'axios';
import { motion, AnimatePresence } from 'framer-motion';
import useTheme from '../../stores/themeStore';
import getThemeStyles from '../../lib/theme-utils';
Expand All @@ -18,7 +19,14 @@ import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';

// Import modular components and types
import { User, PermissionComponent, PermissionLevel, UserFilter } from './UserTypes';
import {
User,
PermissionComponent,
PermissionLevel,
UserFilter,
PermissionsMap,
PermissionValue,
} from './UserTypes';
import UserFormModal from './UserFormModal';
import DeleteUserModal from './DeleteUserModal';
import UserList from './UserList';
Expand Down Expand Up @@ -142,6 +150,36 @@ const CustomDropdown = ({
);
};

const extractApiErrorMessage = (error: unknown, fallbackMessage: string): string => {
if (axios.isAxiosError(error)) {
const data = error.response?.data as {
details?: string;
error?: string;
message?: string;
};

return data?.details || data?.error || data?.message || error.message || fallbackMessage;
}

if (error instanceof Error && error.message) {
return error.message;
}

return fallbackMessage;
};

const sanitizePermissions = (permissions: PermissionsMap): Record<string, string> => {
return Object.entries(permissions).reduce(
(acc, [component, value]) => {
if (value) {
acc[component] = value;
}
return acc;
},
{} as Record<string, string>
);
};

const UserManagement = () => {
const { t } = useTranslation();
const { theme } = useTheme();
Expand Down Expand Up @@ -177,21 +215,28 @@ const UserManagement = () => {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isUserAdmin, setIsUserAdmin] = useState(false);
const [userPermissions, setUserPermissions] = useState<Record<string, string>>({});
const [userPermissions, setUserPermissions] = useState<PermissionsMap>({});
const [formError, setFormError] = useState<string | null>(null);

// Permissions components that can be managed
const permissionComponents: PermissionComponent[] = [
{ id: 'users', name: t('admin.users.permissions.users') },
{ id: 'resources', name: t('admin.users.permissions.resources') },
{ id: 'system', name: t('admin.users.permissions.system') },
{ id: 'dashboard', name: t('admin.users.permissions.dashboard') },
];
const permissionComponents: PermissionComponent[] = useMemo(
() => [
{ id: 'users', name: t('admin.users.permissions.users') },
{ id: 'resources', name: t('admin.users.permissions.resources') },
{ id: 'system', name: t('admin.users.permissions.system') },
{ id: 'dashboard', name: t('admin.users.permissions.dashboard') },
],
[t]
);

// Available permission levels
const permissionLevels: PermissionLevel[] = [
{ id: 'read', name: t('admin.users.permissions.levels.read') },
{ id: 'write', name: t('admin.users.permissions.levels.write') },
];
const permissionLevels: PermissionLevel[] = useMemo(
() => [
{ id: 'read', name: t('admin.users.permissions.levels.read') },
{ id: 'write', name: t('admin.users.permissions.levels.write') },
],
[t]
);

// Function to fetch users wrapped in useCallback
const fetchUsers = useCallback(async () => {
Expand Down Expand Up @@ -239,7 +284,7 @@ const UserManagement = () => {
Object.keys(user.permissions || {}).some(
key =>
key.toLowerCase().includes(lowerSearchTerm) ||
user.permissions[key].toLowerCase().includes(lowerSearchTerm)
(user.permissions[key]?.toLowerCase().includes(lowerSearchTerm) ?? false)
)
);
}
Expand Down Expand Up @@ -314,6 +359,8 @@ const UserManagement = () => {
};

const handleAddUser = async () => {
setFormError(null);

if (!username || (!passwordOptional && !password)) {
toast.error(t('admin.users.errors.missingFields'));
return;
Expand All @@ -325,8 +372,8 @@ const UserManagement = () => {
}

try {
// If user is admin, set all permissions to write
const finalPermissions = { ...userPermissions };
const finalPermissions = sanitizePermissions(userPermissions);

if (isUserAdmin) {
permissionComponents.forEach(component => {
finalPermissions[component.id] = 'write';
Expand All @@ -341,12 +388,15 @@ const UserManagement = () => {
toast.success(t('admin.users.success.userAdded'));
fetchUsers();
} catch (error) {
const errorMessage = extractApiErrorMessage(error, t('admin.users.errors.addFailed'));
console.error('Error adding user:', error);
toast.error(t('admin.users.errors.addFailed'));
setFormError(errorMessage);
}
};

const handleEditUser = async () => {
setFormError(null);

if (!currentUser || !username) {
toast.error(t('admin.users.errors.missingFields'));
return;
Expand All @@ -358,8 +408,8 @@ const UserManagement = () => {
}

try {
// If user is admin, set all permissions to write
const finalPermissions = { ...userPermissions };
const finalPermissions = sanitizePermissions(userPermissions);

if (isUserAdmin) {
permissionComponents.forEach(component => {
finalPermissions[component.id] = 'write';
Expand All @@ -382,8 +432,9 @@ const UserManagement = () => {
toast.success(t('admin.users.success.userUpdated'));
fetchUsers();
} catch (error) {
const errorMessage = extractApiErrorMessage(error, t('admin.users.errors.updateFailed'));
console.error('Error updating user:', error);
toast.error(t('admin.users.errors.updateFailed'));
setFormError(errorMessage);
}
};

Expand Down Expand Up @@ -426,6 +477,7 @@ const UserManagement = () => {
setUserPermissions(user.permissions || {});
setPassword('');
setConfirmPassword('');
setFormError(null);
setShowEditModal(true);
};

Expand All @@ -441,6 +493,7 @@ const UserManagement = () => {
setIsUserAdmin(false);
setUserPermissions({});
setCurrentUser(null);
setFormError(null);
};

const closeModals = () => {
Expand All @@ -450,12 +503,25 @@ const UserManagement = () => {
resetForm();
};

const handlePermissionChange = (component: string, permission: string) => {
setUserPermissions(prev => ({
...prev,
[component]: permission,
}));
};
const handlePermissionChange = useCallback((component: string, permission: PermissionValue) => {
setUserPermissions(prev => {
// Early return if permission hasn't changed (optimization)
if (prev[component] === permission) {
return prev;
}

if (!permission) {
const rest = { ...prev };
delete rest[component];
return rest;
}

return {
...prev,
[component]: permission,
};
});
}, []);

// Filter handling functions
const toggleFilters = () => {
Expand Down Expand Up @@ -996,6 +1062,7 @@ const UserManagement = () => {
isOpen={showAddModal}
onClose={closeModals}
onSubmit={handleAddUser}
formError={formError ?? undefined}
username={username}
setUsername={setUsername}
password={password}
Expand All @@ -1019,6 +1086,7 @@ const UserManagement = () => {
isOpen={showEditModal}
onClose={closeModals}
onSubmit={handleEditUser}
formError={formError ?? undefined}
username={username}
setUsername={setUsername}
password={password}
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/components/admin/UserTypes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export type PermissionValue = 'read' | 'write' | null;
export type PermissionsMap = Record<string, PermissionValue>;

// User data type
export interface User {
id?: number;
username: string;
is_admin: boolean;
permissions: Record<string, string>;
permissions: PermissionsMap;
created_at?: string;
updated_at?: string;
}
Expand All @@ -25,7 +28,7 @@ export interface PermissionComponent {

// Permission level type
export interface PermissionLevel {
id: string;
id: Exclude<PermissionValue, null>;
name: string;
}

Expand Down Expand Up @@ -59,8 +62,8 @@ export interface UserFormModalProps {
setConfirmPassword: (confirmPassword: string) => void;
isAdmin: boolean;
setIsAdmin: (isAdmin: boolean) => void;
permissions: Record<string, string>;
setPermissionChange: (component: string, permission: string) => void;
permissions: PermissionsMap;
setPermissionChange: (component: string, permission: PermissionValue) => void;
permissionComponents: PermissionComponent[];
permissionLevels: PermissionLevel[];
submitLabel: string;
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ api.interceptors.response.use(
}

const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
const responseData = error.response?.data as {
details?: string;
message?: string;
error?: string;
};
const errorMessage =
error.response?.data?.message || error.response?.data?.error || error.message;
responseData?.details || responseData?.message || responseData?.error || error.message;
const isAuthCheck = error.config?.url?.includes('/api/me');

const isLoginEndpoint = error.config?.url?.includes('/login');
Expand Down
Loading