684 lines
18 KiB
TypeScript
684 lines
18 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useBanUserMutation, useCreateUserMutation, useDeleteUserMutation, useInviteUserMutation, useUnbanUserMutation, useUpdateUserMutation } from './queries';
|
|
import { IUserSchema, IUserFilterOptionsSchema } from '@/src/entities/models/users/users.model';
|
|
import { toast } from 'sonner';
|
|
import { set } from 'date-fns';
|
|
import { CreateUserSchema, defaulICreateUserSchemaValues, ICreateUserSchema } from '@/src/entities/models/users/create-user.model';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { defaulIInviteUserSchemaValues, IInviteUserSchema, InviteUserSchema } from '@/src/entities/models/users/invite-user.model';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { useSendMagicLinkMutation, useSendPasswordRecoveryMutation } from '@/app/(pages)/(auth)/queries';
|
|
import { ValidBanDuration } from '@/app/_lib/types/ban-duration';
|
|
import { IUpdateUserSchema, UpdateUserSchema } from '@/src/entities/models/users/update-user.model';
|
|
|
|
export const useUsersHandlers = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
// Core mutations
|
|
const { updateUser, isPending: isUpdatePending, errors: isUpdateError } = useUpdateUserMutation();
|
|
const { deleteUser, isPending: isDeletePending } = useDeleteUserMutation();
|
|
const { sendPasswordRecovery, isPending: isSendPasswordRecoveryPending } = useSendPasswordRecoveryMutation();
|
|
const { sendMagicLink, isPending: isSendMagicLinkPending } = useSendMagicLinkMutation();
|
|
const { banUser, isPending: isBanPending } = useBanUserMutation();
|
|
const { unbanUser, isPending: isUnbanPending } = useUnbanUserMutation();
|
|
|
|
|
|
/**
|
|
* update a user by ID
|
|
*/
|
|
|
|
const handleUpdateUser = async (userId: string, data: IUpdateUserSchema, options?: {
|
|
onSuccess?: () => void,
|
|
onError?: (error: unknown) => void
|
|
}) => {
|
|
await updateUser({ id: userId, data }, {
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
|
|
toast.success("User updated successfully");
|
|
options?.onSuccess?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to update user");
|
|
options?.onError?.(error);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deletes a user by ID
|
|
*/
|
|
const handleDeleteUser = async (userId: string, options?: {
|
|
onSuccess?: () => void,
|
|
onError?: (error: unknown) => void
|
|
}) => {
|
|
await deleteUser(userId, {
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
toast.success("User deleted successfully");
|
|
options?.onSuccess?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to delete user");
|
|
options?.onError?.(error);
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Sends a password recovery email to the user
|
|
*/
|
|
const handleSendPasswordRecovery = async (email: string, options?: {
|
|
onSuccess?: () => void,
|
|
onError?: (error: unknown) => void
|
|
}) => {
|
|
if (!email) {
|
|
toast.error("No email address provided");
|
|
options?.onError?.(new Error("No email address provided"));
|
|
return;
|
|
}
|
|
|
|
await sendPasswordRecovery(email, {
|
|
onSuccess: () => {
|
|
toast.success("Recovery email sent");
|
|
options?.onSuccess?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to send recovery email");
|
|
options?.onError?.(error);
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Sends a magic link to the user's email
|
|
*/
|
|
const handleSendMagicLink = async (email: string, options?: {
|
|
onSuccess?: () => void,
|
|
onError?: (error: unknown) => void
|
|
}) => {
|
|
if (!email) {
|
|
toast.error("No email address provided");
|
|
options?.onError?.(new Error("No email address provided"));
|
|
return;
|
|
}
|
|
|
|
await sendMagicLink(email, {
|
|
onSuccess: () => {
|
|
toast.success("Magic link sent");
|
|
options?.onSuccess?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to send magic link");
|
|
options?.onError?.(error);
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Bans a user for the specified duration
|
|
*/
|
|
const handleBanUser = async (userId: string, banDuration: ValidBanDuration = "24h", options?: {
|
|
onSuccess?: () => void,
|
|
onError?: (error: unknown) => void
|
|
}) => {
|
|
await banUser({ credential: { id: userId }, data: { ban_duration: banDuration } }, {
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
toast.success("User banned successfully");
|
|
options?.onSuccess?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to ban user");
|
|
options?.onError?.(error);
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Unbans a user
|
|
*/
|
|
const handleUnbanUser = async (userId: string, options?: {
|
|
onSuccess?: () => void,
|
|
onError?: (error: unknown) => void
|
|
}) => {
|
|
await unbanUser({ id: userId }, {
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
toast.success("User unbanned successfully");
|
|
options?.onSuccess?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Failed to unban user");
|
|
options?.onError?.(error);
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Toggles a user's ban status
|
|
*/
|
|
const handleToggleBan = async (user: { id: string, banned_until?: ValidBanDuration }, options?: {
|
|
onSuccess?: () => void,
|
|
onError?: (error: unknown) => void,
|
|
banDuration?: ValidBanDuration
|
|
}) => {
|
|
if (user.banned_until) {
|
|
await handleUnbanUser(user.id, options);
|
|
} else {
|
|
await handleBanUser(user.id, options?.banDuration, options);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Copies text to clipboard
|
|
*/
|
|
const handleCopyItem = (item: string, options?: {
|
|
onSuccess?: () => void,
|
|
onError?: (error: unknown) => void
|
|
}) => {
|
|
if (!navigator.clipboard) {
|
|
const error = new Error("Clipboard not supported");
|
|
toast.error("Clipboard not supported");
|
|
options?.onError?.(error);
|
|
return;
|
|
}
|
|
|
|
if (!item) {
|
|
const error = new Error("Nothing to copy");
|
|
toast.error("Nothing to copy");
|
|
options?.onError?.(error);
|
|
return;
|
|
}
|
|
|
|
navigator.clipboard.writeText(item)
|
|
.then(() => {
|
|
toast.success("Copied to clipboard");
|
|
options?.onSuccess?.();
|
|
})
|
|
.catch((error) => {
|
|
toast.error("Failed to copy to clipboard");
|
|
options?.onError?.(error);
|
|
});
|
|
};
|
|
|
|
return {
|
|
// Action handlers
|
|
updateUser: handleUpdateUser,
|
|
deleteUser: handleDeleteUser,
|
|
sendPasswordRecovery: handleSendPasswordRecovery,
|
|
sendMagicLink: handleSendMagicLink,
|
|
banUser: handleBanUser,
|
|
unbanUser: handleUnbanUser,
|
|
toggleBan: handleToggleBan,
|
|
copyToClipboard: handleCopyItem,
|
|
|
|
// Loading states
|
|
isUpdatePending,
|
|
isDeletePending,
|
|
isSendPasswordRecoveryPending,
|
|
isSendMagicLinkPending,
|
|
isBanPending,
|
|
isUnbanPending,
|
|
|
|
// Errors
|
|
isUpdateError,
|
|
};
|
|
};
|
|
|
|
// Specific handler for the component
|
|
|
|
export const useAddUserDialogHandler = ({ onUserAdded, onOpenChange }: {
|
|
onUserAdded: () => void;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) => {
|
|
|
|
const queryClient = useQueryClient();
|
|
const { createUser, isPending } = useCreateUserMutation();
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
formState: { errors: errors },
|
|
setError,
|
|
getValues,
|
|
clearErrors,
|
|
watch,
|
|
} = useForm<ICreateUserSchema>({
|
|
resolver: zodResolver(CreateUserSchema),
|
|
defaultValues: {
|
|
email: "",
|
|
password: "",
|
|
email_confirm: true,
|
|
}
|
|
});
|
|
|
|
const emailConfirm = watch("email_confirm");
|
|
|
|
const onSubmit = handleSubmit(async (data) => {
|
|
|
|
await createUser(data, {
|
|
onSuccess: () => {
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
|
|
toast.success("User created successfully.");
|
|
|
|
onUserAdded();
|
|
onOpenChange(false);
|
|
reset();
|
|
},
|
|
onError: (error) => {
|
|
reset();
|
|
toast.error(error.message);
|
|
},
|
|
});
|
|
|
|
});
|
|
|
|
const handleOpenChange = (open: boolean) => {
|
|
if (!open) {
|
|
reset();
|
|
}
|
|
onOpenChange(open);
|
|
};
|
|
|
|
return {
|
|
register,
|
|
handleSubmit: onSubmit,
|
|
reset,
|
|
errors,
|
|
isPending,
|
|
getValues,
|
|
clearErrors,
|
|
emailConfirm,
|
|
handleOpenChange,
|
|
};
|
|
}
|
|
|
|
export const useInviteUserHandler = ({ onUserInvited, onOpenChange }: {
|
|
onUserInvited: () => void;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) => {
|
|
|
|
const queryClient = useQueryClient();
|
|
const { inviteUser, isPending } = useInviteUserMutation();
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
formState: { errors: errors },
|
|
setError,
|
|
getValues,
|
|
clearErrors,
|
|
watch,
|
|
} = useForm<IInviteUserSchema>({
|
|
resolver: zodResolver(InviteUserSchema),
|
|
defaultValues: defaulIInviteUserSchemaValues
|
|
})
|
|
|
|
const onSubmit = handleSubmit(async (data) => {
|
|
await inviteUser(data, {
|
|
onSuccess: () => {
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
|
|
toast.success("Invitation sent");
|
|
|
|
onUserInvited();
|
|
onOpenChange(false);
|
|
reset();
|
|
},
|
|
onError: () => {
|
|
reset();
|
|
toast.error("Failed to send invitation");
|
|
},
|
|
});
|
|
});
|
|
|
|
const handleOpenChange = (open: boolean) => {
|
|
if (!open) {
|
|
reset();
|
|
}
|
|
onOpenChange(open);
|
|
};
|
|
|
|
return {
|
|
register,
|
|
handleSubmit: onSubmit,
|
|
handleOpenChange,
|
|
reset,
|
|
getValues,
|
|
clearErrors,
|
|
watch,
|
|
errors,
|
|
isPending,
|
|
};
|
|
}
|
|
|
|
|
|
export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenChange }: {
|
|
open: boolean;
|
|
user: IUserSchema;
|
|
onUserUpdated: () => void;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) => {
|
|
const {
|
|
deleteUser,
|
|
sendPasswordRecovery,
|
|
sendMagicLink,
|
|
banUser,
|
|
unbanUser,
|
|
toggleBan,
|
|
copyToClipboard,
|
|
isDeletePending,
|
|
isSendPasswordRecoveryPending,
|
|
isSendMagicLinkPending,
|
|
isBanPending,
|
|
isUnbanPending,
|
|
} = useUsersHandlers();
|
|
|
|
const handleDeleteUser = async () => {
|
|
await deleteUser(user.id, {
|
|
onSuccess: () => {
|
|
onOpenChange(false);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleSendPasswordRecovery = async () => {
|
|
if (!user.email) {
|
|
toast.error("User has no email address");
|
|
return;
|
|
}
|
|
await sendPasswordRecovery(user.email);
|
|
};
|
|
|
|
const handleSendMagicLink = async () => {
|
|
if (!user.email) {
|
|
toast.error("User has no email address");
|
|
return;
|
|
}
|
|
await sendMagicLink(user.email);
|
|
};
|
|
|
|
const handleBanUser = async () => {
|
|
await banUser(user.id, "24h", {
|
|
onSuccess: onUserUpdated
|
|
});
|
|
};
|
|
|
|
const handleUnbanUser = async () => {
|
|
await unbanUser(user.id, {
|
|
onSuccess: onUserUpdated
|
|
});
|
|
};
|
|
|
|
const handleToggleBan = async () => {
|
|
await toggleBan({ id: user.id }, {
|
|
onSuccess: onUserUpdated
|
|
});
|
|
};
|
|
|
|
return {
|
|
handleDeleteUser,
|
|
handleSendPasswordRecovery,
|
|
handleSendMagicLink,
|
|
handleBanUser,
|
|
handleUnbanUser,
|
|
handleToggleBan,
|
|
handleCopyItem: copyToClipboard,
|
|
isDeletePending,
|
|
isSendPasswordRecoveryPending,
|
|
isSendMagicLinkPending,
|
|
isBanPending,
|
|
isUnbanPending,
|
|
};
|
|
};
|
|
|
|
export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUserUpdated }: {
|
|
open: boolean;
|
|
userData: IUserSchema;
|
|
onOpenChange: (open: boolean) => void;
|
|
onUserUpdated: () => void;
|
|
}) => {
|
|
|
|
const { updateUser, isUpdatePending, isUpdateError } = useUsersHandlers();
|
|
|
|
// Initialize form with user data
|
|
const form = useForm<IUpdateUserSchema>({
|
|
resolver: zodResolver(UpdateUserSchema),
|
|
defaultValues: {
|
|
email: userData?.email || undefined,
|
|
encrypted_password: userData?.encrypted_password || undefined,
|
|
role: (userData?.role as "user" | "staff" | "admin") || "user",
|
|
phone: userData?.phone || undefined,
|
|
invited_at: userData?.invited_at || undefined,
|
|
confirmed_at: userData?.confirmed_at || undefined,
|
|
// recovery_sent_at: userData?.recovery_sent_at || undefined,
|
|
last_sign_in_at: userData?.last_sign_in_at || undefined,
|
|
created_at: userData?.created_at || undefined,
|
|
updated_at: userData?.updated_at || undefined,
|
|
is_anonymous: userData?.is_anonymous || false,
|
|
profile: {
|
|
// id: userData?.profile?.id || undefined,
|
|
// user_id: userData?.profile?.user_id || undefined,
|
|
avatar: userData?.profile?.avatar || undefined,
|
|
username: userData?.profile?.username || undefined,
|
|
first_name: userData?.profile?.first_name || undefined,
|
|
last_name: userData?.profile?.last_name || undefined,
|
|
bio: userData?.profile?.bio || undefined,
|
|
address: userData?.profile?.address || {
|
|
street: "",
|
|
city: "",
|
|
state: "",
|
|
country: "",
|
|
postal_code: "",
|
|
},
|
|
birth_date: userData?.profile?.birth_date ? new Date(userData.profile.birth_date) : undefined,
|
|
},
|
|
},
|
|
})
|
|
|
|
const handleUpdateUser = async () => {
|
|
await updateUser(userData.id, form.getValues(), {
|
|
onSuccess: () => {
|
|
onUserUpdated();
|
|
onOpenChange(false);
|
|
},
|
|
onError: () => {
|
|
onOpenChange(false);
|
|
},
|
|
});
|
|
|
|
}
|
|
|
|
return {
|
|
handleUpdateUser,
|
|
form,
|
|
isUpdatePending,
|
|
};
|
|
}
|
|
|
|
export const useUserManagementHandlers = (refetch: () => void) => {
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [detailUser, setDetailUser] = useState<IUserSchema | null>(null)
|
|
const [updateUser, setUpdateUser] = useState<IUserSchema | null>(null)
|
|
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
|
const [isUpdateOpen, setIsUpdateOpen] = useState(false)
|
|
const [isAddUserOpen, setIsAddUserOpen] = useState(false)
|
|
const [isInviteUserOpen, setIsInviteUserOpen] = useState(false)
|
|
|
|
// Filter states
|
|
const [filters, setFilters] = useState<IUserFilterOptionsSchema>({
|
|
email: "",
|
|
phone: "",
|
|
lastSignIn: "",
|
|
createdAt: "",
|
|
status: [],
|
|
})
|
|
|
|
// Handle opening the detail sheet
|
|
const handleUserClick = (user: IUserSchema) => {
|
|
setDetailUser(user)
|
|
setIsSheetOpen(true)
|
|
}
|
|
|
|
// Handle opening the update sheet
|
|
const handleUserUpdate = (user: IUserSchema) => {
|
|
setUpdateUser(user)
|
|
setIsUpdateOpen(true)
|
|
}
|
|
|
|
// Close detail sheet when update sheet opens
|
|
useEffect(() => {
|
|
if (isUpdateOpen) {
|
|
setIsSheetOpen(false)
|
|
}
|
|
}, [isUpdateOpen])
|
|
|
|
// Reset detail user when sheet closes
|
|
useEffect(() => {
|
|
if (!isSheetOpen) {
|
|
// Use a small delay to prevent flickering if another sheet is opening
|
|
const timer = setTimeout(() => {
|
|
if (!isSheetOpen && !isUpdateOpen) {
|
|
setDetailUser(null)
|
|
}
|
|
}, 300)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [isSheetOpen, isUpdateOpen])
|
|
|
|
// Reset update user when update sheet closes
|
|
useEffect(() => {
|
|
if (!isUpdateOpen) {
|
|
// Use a small delay to prevent flickering if another sheet is opening
|
|
const timer = setTimeout(() => {
|
|
if (!isUpdateOpen) {
|
|
setUpdateUser(null)
|
|
}
|
|
}, 300)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [isUpdateOpen])
|
|
|
|
const clearFilters = () => {
|
|
setFilters({
|
|
email: "",
|
|
phone: "",
|
|
lastSignIn: "",
|
|
createdAt: "",
|
|
status: [],
|
|
})
|
|
}
|
|
|
|
const getActiveFilterCount = () => {
|
|
return Object.values(filters).filter(
|
|
(value) => (typeof value === "string" && value !== "") || (Array.isArray(value) && value.length > 0),
|
|
).length
|
|
}
|
|
|
|
return {
|
|
searchQuery,
|
|
setSearchQuery,
|
|
detailUser,
|
|
updateUser,
|
|
isSheetOpen,
|
|
setIsSheetOpen,
|
|
isUpdateOpen,
|
|
setIsUpdateOpen,
|
|
isAddUserOpen,
|
|
setIsAddUserOpen,
|
|
isInviteUserOpen,
|
|
setIsInviteUserOpen,
|
|
filters,
|
|
setFilters,
|
|
handleUserClick,
|
|
handleUserUpdate,
|
|
clearFilters,
|
|
getActiveFilterCount,
|
|
}
|
|
}
|
|
|
|
export const filterUsers = (users: IUserSchema[], searchQuery: string, filters: IUserFilterOptionsSchema): IUserSchema[] => {
|
|
return users.filter((user) => {
|
|
|
|
// Global search
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase()
|
|
const matchesSearch =
|
|
user.email?.toLowerCase().includes(query) ||
|
|
user.phone?.toLowerCase().includes(query) ||
|
|
user.id.toLowerCase().includes(query)
|
|
|
|
if (!matchesSearch) return false
|
|
}
|
|
|
|
// Email filter
|
|
if (filters.email && !user.email?.toLowerCase().includes(filters.email.toLowerCase())) {
|
|
return false
|
|
}
|
|
|
|
// Phone filter
|
|
if (filters.phone && !user.phone?.toLowerCase().includes(filters.phone.toLowerCase())) {
|
|
return false
|
|
}
|
|
|
|
// Last sign in filter
|
|
if (filters.lastSignIn) {
|
|
if (filters.lastSignIn === "never" && user.last_sign_in_at) {
|
|
return false
|
|
} else if (filters.lastSignIn === "today") {
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
const signInDate = user.last_sign_in_at ? new Date(user.last_sign_in_at) : null
|
|
if (!signInDate || signInDate < today) return false
|
|
} else if (filters.lastSignIn === "week") {
|
|
const weekAgo = new Date()
|
|
weekAgo.setDate(weekAgo.getDate() - 7)
|
|
const signInDate = user.last_sign_in_at ? new Date(user.last_sign_in_at) : null
|
|
if (!signInDate || signInDate < weekAgo) return false
|
|
} else if (filters.lastSignIn === "month") {
|
|
const monthAgo = new Date()
|
|
monthAgo.setMonth(monthAgo.getMonth() - 1)
|
|
const signInDate = user.last_sign_in_at ? new Date(user.last_sign_in_at) : null
|
|
if (!signInDate || signInDate < monthAgo) return false
|
|
}
|
|
}
|
|
|
|
// Created at filter
|
|
if (filters.createdAt) {
|
|
if (filters.createdAt === "today") {
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
const createdAt = user.created_at ? (user.created_at ? new Date(user.created_at) : new Date()) : new Date()
|
|
if (createdAt < today) return false
|
|
} else if (filters.createdAt === "week") {
|
|
const weekAgo = new Date()
|
|
weekAgo.setDate(weekAgo.getDate() - 7)
|
|
const createdAt = user.created_at ? new Date(user.created_at) : new Date()
|
|
if (createdAt < weekAgo) return false
|
|
} else if (filters.createdAt === "month") {
|
|
const monthAgo = new Date()
|
|
monthAgo.setMonth(monthAgo.getMonth() - 1)
|
|
const createdAt = user.created_at ? new Date(user.created_at) : new Date()
|
|
if (createdAt < monthAgo) return false
|
|
}
|
|
}
|
|
|
|
// Status filter
|
|
if (filters.status.length > 0) {
|
|
const userStatus = user.banned_until ? "banned" : !user.email_confirmed_at ? "unconfirmed" : "active"
|
|
|
|
if (!filters.status.includes(userStatus)) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
})
|
|
} |