diff --git a/deepfence_frontend/apps/dashboard/src/api/api.ts b/deepfence_frontend/apps/dashboard/src/api/api.ts index 2276dc8a0f..9f2de23b63 100644 --- a/deepfence_frontend/apps/dashboard/src/api/api.ts +++ b/deepfence_frontend/apps/dashboard/src/api/api.ts @@ -42,6 +42,12 @@ export function getUserApiClient() { const userApi = new UserApi(configuration); return { registerUser: userApi.registerUser.bind(userApi), + getUsers: userApi.getUsers.bind(userApi), + getUser: userApi.getUser.bind(userApi), + updateUser: userApi.updateUser.bind(userApi), + deleteUser: userApi.deleteUser.bind(userApi), + updatePassword: userApi.updatePassword.bind(userApi), + inviteUser: userApi.inviteUser.bind(userApi), }; } diff --git a/deepfence_frontend/apps/dashboard/src/features/settings/pages/ChangePassword.tsx b/deepfence_frontend/apps/dashboard/src/features/settings/pages/ChangePassword.tsx new file mode 100644 index 0000000000..842dbcf071 --- /dev/null +++ b/deepfence_frontend/apps/dashboard/src/features/settings/pages/ChangePassword.tsx @@ -0,0 +1,153 @@ +import { IconContext } from 'react-icons'; +import { HiArrowSmLeft } from 'react-icons/hi'; +import { Link, useFetcher } from 'react-router-dom'; +import { ActionFunction, redirect } from 'react-router-dom'; +import { toast } from 'sonner'; +import { Button, Card, TextInput } from 'ui-components'; + +import { getUserApiClient } from '@/api/api'; +import { ApiDocsBadRequestResponse } from '@/api/generated'; +import { DFLink } from '@/components/DFLink'; +import { SettingsTab } from '@/features/settings/components/SettingsTab'; +import { ApiError, makeRequest } from '@/utils/api'; + +export type changePasswordActionReturnType = { + error?: string; + fieldErrors?: { + old_password?: string; + new_password?: string; + confirm_password?: string; + }; +}; + +export const action: ActionFunction = async ({ + request, +}): Promise => { + const formData = await request.formData(); + // add console_url which is the origin of request + formData.append('consoleUrl', window.location.origin); + const body = Object.fromEntries(formData); + if (body.new_password !== body.confirm_password) { + return { + fieldErrors: { + confirm_password: 'Password does not match', + }, + }; + } + const r = await makeRequest({ + apiFunction: getUserApiClient().updatePassword, + apiArgs: [ + { + modelUpdateUserPasswordRequest: { + old_password: body.new_password as string, + new_password: body.new_password as string, + }, + }, + ], + errorHandler: async (r) => { + const error = new ApiError({}); + if (r.status === 400) { + const modelResponse: ApiDocsBadRequestResponse = await r.json(); + return error.set({ + fieldErrors: { + old_password: modelResponse.error_fields?.old_password as string, + new_password: modelResponse.error_fields?.new_password as string, + }, + }); + } else if (r.status === 403) { + const modelResponse: ApiDocsBadRequestResponse = await r.json(); + return error.set({ + error: modelResponse.message, + }); + } + }, + }); + + if (ApiError.isApiError(r)) { + return r.value(); + } + toast.success('Password changed successfully'); + throw redirect('/settings/user-management', 302); +}; + +const ChangePassword = () => { + const fetcher = useFetcher(); + const { data } = fetcher; + return ( + <> + +
+ + + + + Back + + User Profile +
+ + + + + + + + + + + +
+ + ); +}; + +export const module = { + element: , + action, +}; diff --git a/deepfence_frontend/apps/dashboard/src/features/settings/pages/EditUser.tsx b/deepfence_frontend/apps/dashboard/src/features/settings/pages/EditUser.tsx new file mode 100644 index 0000000000..cb9c32171b --- /dev/null +++ b/deepfence_frontend/apps/dashboard/src/features/settings/pages/EditUser.tsx @@ -0,0 +1,241 @@ +import { Suspense } from 'react'; +import { IconContext } from 'react-icons'; +import { HiArrowSmLeft } from 'react-icons/hi'; +import { + Link, + LoaderFunctionArgs, + useFetcher, + useLoaderData, + useParams, +} from 'react-router-dom'; +import { ActionFunction, redirect } from 'react-router-dom'; +import { toast } from 'sonner'; +import { + Button, + Card, + CircleSpinner, + Select, + SelectItem, + TextInput, +} from 'ui-components'; + +import { getUserApiClient } from '@/api/api'; +import { + ApiDocsBadRequestResponse, + ModelUpdateUserIdRequestRoleEnum, +} from '@/api/generated'; +import { ModelUser } from '@/api/generated/models/ModelUser'; +import { DFLink } from '@/components/DFLink'; +import { SettingsTab } from '@/features/settings/components/SettingsTab'; +import { ApiError, makeRequest } from '@/utils/api'; +import { typedDefer, TypedDeferredData } from '@/utils/router'; +import { DFAwait } from '@/utils/suspense'; + +export type UpdateActionReturnType = { + error?: string; + fieldErrors?: { + firstName?: string; + lastName?: string; + role?: string; + status?: string; + }; +}; + +export const action: ActionFunction = async ({ + request, +}): Promise => { + const formData = await request.formData(); + // add console_url which is the origin of request + formData.append('consoleUrl', window.location.origin); + const body = Object.fromEntries(formData); + + const r = await makeRequest({ + apiFunction: getUserApiClient().updateUser, + apiArgs: [ + { + id: Number(body.id), + modelUpdateUserIdRequest: { + first_name: body.firstName as string, + last_name: body.lastName as string, + role: body.role as ModelUpdateUserIdRequestRoleEnum, + is_active: body.status === 'true', + }, + }, + ], + errorHandler: async (r) => { + const error = new ApiError({}); + if (r.status === 400) { + const modelResponse: ApiDocsBadRequestResponse = await r.json(); + return error.set({ + fieldErrors: { + firstName: modelResponse.error_fields?.first_name as string, + lastName: modelResponse.error_fields?.last_name as string, + status: modelResponse.error_fields?.is_active as string, + role: modelResponse.error_fields?.role as string, + }, + }); + } else if (r.status === 403) { + const modelResponse: ApiDocsBadRequestResponse = await r.json(); + return error.set({ + error: modelResponse.message, + }); + } + }, + }); + + if (ApiError.isApiError(r)) { + return r.value(); + } + toast.success('User details updated successfully'); + throw redirect('/settings/user-management', 302); +}; + +type LoaderDataType = { + message?: string; + data?: ModelUser; +}; +const getUser = async (userId: number): Promise => { + const usersPromise = await makeRequest({ + apiFunction: getUserApiClient().getUser, + apiArgs: [{ id: userId }], + }); + + if (ApiError.isApiError(usersPromise)) { + return { + message: 'Error in getting users list', + }; + } + + return { + data: usersPromise, + }; +}; +const loader = async ({ + params, +}: LoaderFunctionArgs): Promise> => { + return typedDefer({ + data: getUser(Number(params.userId)), + }); +}; +const EditUser = () => { + const loaderData = useLoaderData() as LoaderDataType; + const fetcher = useFetcher(); + const { data } = fetcher; + const { userId } = useParams() as { + userId: string; + }; + + if (!userId) { + throw new Error('User ID is required'); + } + return ( + <> + +
+ + + + + Back + + User Profile +
+ + }> + + {(user: LoaderDataType) => { + return ( + + + + +
+ +
+
+ +
+ + + + +
+ ); + }} +
+
+
+
+ + ); +}; + +export const module = { + element: , + loader, + action, +}; diff --git a/deepfence_frontend/apps/dashboard/src/features/settings/pages/InviteUser.tsx b/deepfence_frontend/apps/dashboard/src/features/settings/pages/InviteUser.tsx new file mode 100644 index 0000000000..135b5a2e3e --- /dev/null +++ b/deepfence_frontend/apps/dashboard/src/features/settings/pages/InviteUser.tsx @@ -0,0 +1,174 @@ +import { IconContext } from 'react-icons'; +import { HiArrowSmLeft } from 'react-icons/hi'; +import { useFetcher } from 'react-router-dom'; +import { ActionFunction, redirect } from 'react-router-dom'; +import { toast } from 'sonner'; +import { Button, Card, Select, SelectItem, TextInput, Typography } from 'ui-components'; + +import { getUserApiClient } from '@/api/api'; +import { + ApiDocsBadRequestResponse, + ModelInviteUserRequestActionEnum, + ModelInviteUserRequestRoleEnum, + ModelUpdateUserIdRequestRoleEnum, +} from '@/api/generated'; +import { DFLink } from '@/components/DFLink'; +import { SettingsTab } from '@/features/settings/components/SettingsTab'; +import { ApiError, makeRequest } from '@/utils/api'; + +export type inviteUserActionReturnType = { + error?: string; + fieldErrors?: { + email?: string; + role?: string; + }; + message?: string; + invite_url?: string; + invite_expiry_hours?: number; +}; + +export const action: ActionFunction = async ({ + request, +}): Promise => { + const formData = await request.formData(); + // add console_url which is the origin of request + formData.append('consoleUrl', window.location.origin); + const body = Object.fromEntries(formData); + + const r = await makeRequest({ + apiFunction: getUserApiClient().inviteUser, + apiArgs: [ + { + modelInviteUserRequest: { + action: body.intent as ModelInviteUserRequestActionEnum, + email: body.email as string, + role: body.role as ModelInviteUserRequestRoleEnum, + }, + }, + ], + errorHandler: async (r) => { + const error = new ApiError({}); + if (r.status === 400) { + const modelResponse: ApiDocsBadRequestResponse = await r.json(); + return error.set({ + fieldErrors: { + email: modelResponse.error_fields?.email as string, + role: modelResponse.error_fields?.role as string, + }, + }); + } else if (r.status === 403) { + const modelResponse: ApiDocsBadRequestResponse = await r.json(); + return error.set({ + error: modelResponse.message, + }); + } + }, + }); + + if (ApiError.isApiError(r)) { + return r.value(); + } + if (body.intent == ModelInviteUserRequestActionEnum['GetInviteLink']) { + r.invite_url && navigator.clipboard.writeText(r.invite_url); + toast.success('User Invite URL copied!!!'); + return r; + } + toast.success('User Invite Sent successfully'); + throw redirect('/settings/user-management', 302); +}; + +const InviteUser = () => { + const fetcher = useFetcher(); + const { data } = fetcher; + return ( + <> + +
+ + + + + Back + + + User Profile +
+ + + +
+ +
+ + + +
+ {data?.invite_url && ( +

+ Invite URL:{data?.invite_url}, invite will expire after{' '} + {data?.invite_expiry_hours} hours +

+ )} +
+
+ + ); +}; + +export const module = { + element: , + action, +}; diff --git a/deepfence_frontend/apps/dashboard/src/features/settings/pages/UserManagement.tsx b/deepfence_frontend/apps/dashboard/src/features/settings/pages/UserManagement.tsx index 19d96bca7b..c8863e23d5 100644 --- a/deepfence_frontend/apps/dashboard/src/features/settings/pages/UserManagement.tsx +++ b/deepfence_frontend/apps/dashboard/src/features/settings/pages/UserManagement.tsx @@ -1,13 +1,329 @@ +import { Suspense, useCallback, useMemo, useState } from 'react'; +import { IconContext } from 'react-icons'; +import { FaPencilAlt, FaTrashAlt } from 'react-icons/fa'; +import { HiDotsVertical, HiOutlineExclamationCircle } from 'react-icons/hi'; +import { + ActionFunctionArgs, + generatePath, + Link, + useFetcher, + useLoaderData, +} from 'react-router-dom'; +import { toast } from 'sonner'; +import { + Button, + createColumnHelper, + Dropdown, + DropdownItem, + Modal, + Table, + TableSkeleton, +} from 'ui-components'; + +import { getUserApiClient } from '@/api/api'; +import { ApiDocsBadRequestResponse } from '@/api/generated'; +import { ModelUser } from '@/api/generated/models/ModelUser'; import { SettingsTab } from '@/features/settings/components/SettingsTab'; +import { ApiError, makeRequest } from '@/utils/api'; +import { typedDefer, TypedDeferredData } from '@/utils/router'; +import { DFAwait } from '@/utils/suspense'; +import { usePageNavigation } from '@/utils/usePageNavigation'; + +type LoaderDataType = { + message?: string; + data?: ModelUser[]; +}; +const getUsers = async (): Promise => { + const usersPromise = await makeRequest({ + apiFunction: getUserApiClient().getUsers, + apiArgs: [], + }); + + if (ApiError.isApiError(usersPromise)) { + return { + message: 'Error in getting users list', + }; + } + + return { + data: usersPromise, + }; +}; +const loader = async (): Promise> => { + return typedDefer({ + data: getUsers(), + }); +}; + +export type ActionReturnType = { + message?: string; + success: boolean; +}; + +export const action = async ({ + request, +}: ActionFunctionArgs): Promise => { + const formData = await request.formData(); + const id = Number(formData.get('userId')); + const r = await makeRequest({ + apiFunction: getUserApiClient().deleteUser, + apiArgs: [ + { + id, + }, + ], + errorHandler: async (r) => { + const error = new ApiError({ success: false }); + if (r.status === 400) { + const modelResponse: ApiDocsBadRequestResponse = await r.json(); + return error.set({ + message: modelResponse.message ?? '', + success: false, + }); + } + }, + }); + + if (ApiError.isApiError(r)) { + return r.value(); + } + + toast('User account deleted sucessfully'); + return { + success: true, + }; +}; +const ActionDropdown = ({ id }: { id: string }) => { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const { navigate } = usePageNavigation(); + return ( + <> + {showDeleteDialog && ( + + )} + + { + navigate( + generatePath('/settings/user-management/edit/:userId', { + userId: id, + }), + ); + }} + > + + + + + Edit + + + { + setShowDeleteDialog(true); + }} + > + + + + + Delete + + + + } + > + + + + ); +}; const UserManagement = () => { + const columnHelper = createColumnHelper(); + const loaderData = useLoaderData() as LoaderDataType; + const columns = useMemo(() => { + const columns = [ + columnHelper.accessor('id', { + cell: (cell) => cell.getValue(), + header: () => 'ID', + minSize: 30, + size: 30, + maxSize: 85, + }), + columnHelper.accessor('first_name', { + cell: (cell) => cell.getValue(), + header: () => 'First Name', + minSize: 30, + size: 80, + maxSize: 85, + }), + columnHelper.accessor('last_name', { + cell: (cell) => cell.getValue(), + header: () => 'Last Name', + minSize: 30, + size: 80, + maxSize: 85, + }), + columnHelper.accessor('email', { + cell: (cell) => cell.getValue(), + header: () => 'Email', + minSize: 30, + size: 80, + maxSize: 85, + }), + columnHelper.accessor('role', { + cell: (cell) => cell.getValue(), + header: () => 'Role', + minSize: 30, + size: 80, + maxSize: 85, + }), + columnHelper.display({ + id: 'actions', + enableSorting: false, + cell: (cell) => { + if (!cell.row.original.id) { + throw new Error('User id not found'); + } + return ; + }, + header: () => '', + minSize: 20, + size: 20, + maxSize: 20, + enableResizing: false, + }), + ]; + return columns; + }, []); + return ( -
User management
+
+ }> + + {(resolvedData: LoaderDataType) => { + const { data, message } = resolvedData; + const users = data ?? []; + + return ( +
+
+

+ User Accounts +

+
+ + + + + + +
+
+ + {message ? ( +

{message}

+ ) : ( + + )} + + ); + }} + + + ); }; export const module = { element: , + loader, + action, +}; + +const DeleteConfirmationModal = ({ + showDialog, + userId, + setShowDialog, +}: { + showDialog: boolean; + userId: string; + setShowDialog: React.Dispatch>; +}) => { + const fetcher = useFetcher(); + + const onDeleteAction = useCallback(() => { + const formData = new FormData(); + formData.append('userId', userId); + fetcher.submit(formData, { + method: 'post', + }); + }, [userId, fetcher]); + + return ( + setShowDialog(false)}> +
+ + + +

+ Selected user will be deleted. +
+ Are you sure you want to delete? +

+
+ + +
+
+
+ ); }; diff --git a/deepfence_frontend/apps/dashboard/src/routes/private.tsx b/deepfence_frontend/apps/dashboard/src/routes/private.tsx index b581c406f4..a9022930eb 100644 --- a/deepfence_frontend/apps/dashboard/src/routes/private.tsx +++ b/deepfence_frontend/apps/dashboard/src/routes/private.tsx @@ -63,7 +63,10 @@ import { module as secret } from '@/features/secrets/pages/Secret'; import { module as secretDetails } from '@/features/secrets/pages/SecretDetailModal'; import { module as secretScanResults } from '@/features/secrets/pages/SecretScanResults'; import { module as secretScans } from '@/features/secrets/pages/SecretScans'; +import { module as changePassword } from '@/features/settings/pages/ChangePassword'; import { module as diagnosticLogs } from '@/features/settings/pages/DiagnosticLogs'; +import { module as editUser } from '@/features/settings/pages/EditUser'; +import { module as inviteUser } from '@/features/settings/pages/InviteUser'; import { module as settings } from '@/features/settings/pages/Settings'; import { module as userManagement } from '@/features/settings/pages/UserManagement'; import { module as threatGraphDetailModal } from '@/features/threat-graph/data-components/DetailsModal'; @@ -459,6 +462,21 @@ export const privateRoutes: CustomRouteObject[] = [ ...userManagement, meta: { title: 'User Management' }, }, + { + path: 'user-management/edit/:userId', + ...editUser, + meta: { title: 'Edit User Account' }, + }, + { + path: 'user-management/change-password', + ...changePassword, + meta: { title: 'Change your password' }, + }, + { + path: 'user-management/invite-user', + ...inviteUser, + meta: { title: 'Invite User' }, + }, ], }, ],