diff --git a/web/src/common/form-backend-validation.ts b/web/src/common/form-backend-validation.ts new file mode 100644 index 000000000..af4291b9d --- /dev/null +++ b/web/src/common/form-backend-validation.ts @@ -0,0 +1,12 @@ +import { AxiosError } from 'axios'; +import { FieldValues, Path, UseFormReturn } from 'react-hook-form'; +import { ProblemDetails } from './types'; + +export const addFormValidationErrorsFromBackend = ( + form: UseFormReturn, + error: AxiosError +) => { + error?.response?.data.errors?.forEach((error) => { + form.setError(error.name as Path, { type: 'custom', message: error.reason }); + }); +}; diff --git a/web/src/components/PasswordSetterDialog/PasswordSetterDialog.tsx b/web/src/components/PasswordSetterDialog/PasswordSetterDialog.tsx new file mode 100644 index 000000000..32f55ddc8 --- /dev/null +++ b/web/src/components/PasswordSetterDialog/PasswordSetterDialog.tsx @@ -0,0 +1,75 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { PasswordInput } from '@/components/ui/password-input'; +import { UseFormReturn } from 'react-hook-form'; +import { PasswordSetterFormData } from './usePasswordSetterDialog'; + +export interface CreateNGODialogProps { + displayName: string; + open: boolean; + internalOnOpenChange: (open: any) => void; + form: UseFormReturn; + onSubmit: (values: PasswordSetterFormData) => void; +} + +function PasswordSetterDialog({ displayName, open, form, internalOnOpenChange, onSubmit }: CreateNGODialogProps) { + return ( + + { + e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + e.preventDefault(); + }}> + Set password for {displayName} +
+
+ + ( + + Password + + + + + )} + /> + + ( + + Confirm password + + + + + )} + /> + + + + + + + + + +
+
+
+ ); +} + +export default PasswordSetterDialog; diff --git a/web/src/components/PasswordSetterDialog/usePasswordSetterDialog.ts b/web/src/components/PasswordSetterDialog/usePasswordSetterDialog.ts new file mode 100644 index 000000000..3e16b3a31 --- /dev/null +++ b/web/src/components/PasswordSetterDialog/usePasswordSetterDialog.ts @@ -0,0 +1,105 @@ +import { authApi } from '@/common/auth-api'; +import { addFormValidationErrorsFromBackend } from '@/common/form-backend-validation'; +import { ProblemDetails } from '@/common/types'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { useCallback, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { useDialog } from '../ui/use-dialog'; +import { toast } from '../ui/use-toast'; + +const passwordSetterSchema = z + .object({ + newPassword: z.string().min(8, 'Password must be at least 8 characters long'), + confirmNewPassword: z.string(), + }) + .refine((data) => data.newPassword === data.confirmNewPassword, { + message: 'Passwords do not match', + path: ['confirmNewPassword'], + }); + +type PasswordSetterUserInfoParams = { + userId: string; + displayName: string; +}; + +export type PasswordSetterFormData = z.infer; + +export const usePasswordSetterDialog = () => { + const [userId, setUserId] = useState(''); + const [displayName, setDisplayName] = useState(''); + const passwordSetterDialog = useDialog(); + const { open, onOpenChange } = passwordSetterDialog.dialogProps; + + const form = useForm({ + resolver: zodResolver(passwordSetterSchema), + defaultValues: { + newPassword: '', + confirmNewPassword: '', + }, + }); + + useEffect(() => { + if (form.formState.isSubmitSuccessful) { + form.reset(undefined, { keepValues: true }); + } + }, [form.formState.isSubmitSuccessful]); + + const internalOnOpenChange = useCallback( + (open: boolean) => { + if (!open) { + form.reset({ + newPassword: '', + confirmNewPassword: '', + }); + } + onOpenChange(open); + }, + [onOpenChange] + ); + + const setPasswordMutation = useMutation({ + mutationFn: (data: PasswordSetterFormData) => { + return authApi.post(`auth/set-password`, { + aspNetUserId: userId, + newPassword: data.newPassword, + }); + }, + onSuccess: () => { + form.reset({}); + internalOnOpenChange(false); + toast({ + title: 'Success', + description: 'Password set', + }); + }, + + onError: (error: AxiosError) => { + addFormValidationErrorsFromBackend(form, error); + toast({ + title: 'Error setting password', + description: 'Please contact Platform admins', + variant: 'destructive', + }); + }, + }); + + const handlePasswordSet = (params: PasswordSetterUserInfoParams) => { + setUserId(params.userId); + setDisplayName(params.displayName); + passwordSetterDialog.trigger(); + }; + + const onSubmit = (values: PasswordSetterFormData) => { + setPasswordMutation.mutate(values); + }; + + const passwordSetterDialogProps = { open, form, userId, displayName, onOpenChange, internalOnOpenChange, onSubmit }; + + return { + passwordSetterDialogProps, + handlePasswordSet, + }; +}; diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx index 2568dbd20..e2f836a3c 100644 --- a/web/src/components/ui/input.tsx +++ b/web/src/components/ui/input.tsx @@ -1,17 +1,18 @@ -import { Input as HeadlessInput, type InputProps as HeadlessInputProps } from '@headlessui/react' -import { clsx } from 'clsx' -import { forwardRef } from 'react' +import { Input as HeadlessInput, type InputProps as HeadlessInputProps } from '@headlessui/react'; +import { clsx } from 'clsx'; +import { forwardRef } from 'react'; -const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week'] -type DateType = (typeof dateTypes)[number] +const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']; +type DateType = (typeof dateTypes)[number]; -export const Input = forwardRef< - HTMLInputElement, - { type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | DateType } & HeadlessInputProps ->(function Input({ className, ...props }, ref) { +export type InputProps = { + type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | DateType; +} & HeadlessInputProps; + +export const Input = forwardRef(function Input({ className, ...props }, ref) { return ( + ])}> - ) -}) + ); +}); diff --git a/web/src/components/ui/password-input.tsx b/web/src/components/ui/password-input.tsx new file mode 100644 index 000000000..3b0ee3f91 --- /dev/null +++ b/web/src/components/ui/password-input.tsx @@ -0,0 +1,48 @@ +import { Button } from '@/components/ui/button'; +import { Input, InputProps } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { EyeIcon, EyeOffIcon } from 'lucide-react'; +import { forwardRef, useState } from 'react'; + +const PasswordInput = forwardRef(({ className, ...props }, ref) => { + const [showPassword, setShowPassword] = useState(false); + const disabled = props['value'] === '' || props['value'] === undefined || props['disabled']; + + return ( +
+ + + + {/* hides browsers password toggles */} + +
+ ); +}); +PasswordInput.displayName = 'PasswordInput'; + +export { PasswordInput }; diff --git a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx index a4a56d0b5..5eedce642 100644 --- a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx +++ b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx @@ -69,8 +69,8 @@ export const EditNgoAdmin: FC = ({ ngoAdmin }) => { const handleDelete = async () => { await deleteNgoAdminWithConfirmation({ - adminId, - name: displayName, + userId: adminId, + displayName, onMutationSuccess: () => { navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId, tab: 'admins' } }); }, diff --git a/web/src/features/ngos/components/admins/NGOAdmins.tsx b/web/src/features/ngos/components/admins/NGOAdmins.tsx index 0cc7f56db..e40ddab6b 100644 --- a/web/src/features/ngos/components/admins/NGOAdmins.tsx +++ b/web/src/features/ngos/components/admins/NGOAdmins.tsx @@ -1,3 +1,5 @@ +import PasswordSetterDialog from '@/components/PasswordSetterDialog/PasswordSetterDialog'; +import { usePasswordSetterDialog } from '@/components/PasswordSetterDialog/usePasswordSetterDialog'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; @@ -36,6 +38,7 @@ export const NGOAdminsView: FC = ({ ngoId }) => { const { isFilteringContainerVisible, toggleFilteringContainerVisibility } = useFilteringContainer(); const addNgoAdminDialog = useDialog(); const { queryParams, searchText, handleSearchInput } = useDebouncedSearch(Route.id, ngoAdminsSearchParamsSchema); + const { passwordSetterDialogProps, handlePasswordSet } = usePasswordSetterDialog(); const navigateToViewNgoAdmin = useCallback( (adminId: string) => navigate({ to: '/ngos/admin/$ngoId/$adminId/view', params: { ngoId, adminId } }), @@ -85,9 +88,9 @@ export const NGOAdminsView: FC = ({ ngoId }) => { { id: 'actions', cell: ({ row }) => { - const adminId = row.original.id; - const adminName = `${row.original.firstName} ${row.original.lastName}`; - const isAdminActive = row.original.status === NgoAdminStatus.Active; + const userId = row.original.id; + const displayName = `${row.original.firstName} ${row.original.lastName}`; + const isUserActive = row.original.status === NgoAdminStatus.Active; return (
@@ -99,22 +102,34 @@ export const NGOAdminsView: FC = ({ ngoId }) => { - navigateToViewNgoAdmin(adminId)}>View - navigateToEditNgoAdmin(adminId)}>Edit + navigateToViewNgoAdmin(userId)}>View + { + navigateToEditNgoAdmin(userId); + }}> + Edit + { e.stopPropagation(); - isAdminActive - ? deactivateNgoAdminMutation.mutate(adminId) - : activateNgoAdminMutation.mutate(adminId); + isUserActive ? deactivateNgoAdminMutation.mutate(userId) : activateNgoAdminMutation.mutate(userId); }}> - {!isAdminActive ? 'Activate' : 'Deactivate'} + {!isUserActive ? 'Activate' : 'Deactivate'} + + { + e.stopPropagation(); + handlePasswordSet({ userId, displayName }); + }}> + Set password + + { e.stopPropagation(); - await deleteNgoAdminWithConfirmation({ adminId, name: adminName }); + await deleteNgoAdminWithConfirmation({ userId, displayName }); }}> Delete @@ -158,6 +173,7 @@ export const NGOAdminsView: FC = ({ ngoId }) => { queryParams={queryParams} // onRowClick={(id) => navigateToViewNgoAdmin(id)} /> + ); diff --git a/web/src/features/ngos/hooks/ngo-admin-queries.ts b/web/src/features/ngos/hooks/ngo-admin-queries.ts index be7d3b9b7..e32cb84f3 100644 --- a/web/src/features/ngos/hooks/ngo-admin-queries.ts +++ b/web/src/features/ngos/hooks/ngo-admin-queries.ts @@ -12,9 +12,9 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import { useNavigate, useRouter } from '@tanstack/react-router'; +import axios, { AxiosError } from 'axios'; import { EditNgoAdminFormData, NgoAdmin, NgoAdminFormData, NgoAdminGetRequestParams } from '../models/NgoAdmin'; import { ngosKeys } from './ngos-queries'; -import axios, { AxiosError } from 'axios'; const STALE_TIME = 1000 * 10 * 60; // 10 minutes export const ngoAdminDetailsOptions = ({ ngoId, adminId }: NgoAdminGetRequestParams) => @@ -199,24 +199,24 @@ export const useNgoAdminMutations = (ngoId: string) => { // WRAPPED MUTATIONS const deleteNgoAdminWithConfirmation = async ({ - adminId, - name, + userId, + displayName, onMutationSuccess, }: { - adminId: string; - name: string; + userId: string; + displayName: string; onMutationSuccess?: () => void; }) => { if ( await confirm({ - title: `Delete ${name}?`, + title: `Delete ${displayName}?`, body: 'This action is permanent and cannot be undone. Once deleted, this NGO admin cannot be retrieved.', actionButton: 'Delete', actionButtonClass: buttonVariants({ variant: 'destructive' }), cancelButton: 'Cancel', }) ) { - deleteNgoAdminMutation.mutate({ adminId, onMutationSuccess }); + deleteNgoAdminMutation.mutate({ adminId: userId, onMutationSuccess }); } };