Refactor CRUD Users

This commit is contained in:
vergiLgood1 2025-03-23 09:43:15 +07:00
parent 179ddf6999
commit e84a6f52c0
21 changed files with 891 additions and 280 deletions

View File

@ -39,122 +39,134 @@ import {
} from "@/app/(pages)/(admin)/dashboard/user-management/action"; } from "@/app/(pages)/(admin)/dashboard/user-management/action";
import { format } from "date-fns"; import { format } from "date-fns";
import { sendMagicLink, sendPasswordRecovery } from "@/app/(pages)/(auth)/action"; import { sendMagicLink, sendPasswordRecovery } from "@/app/(pages)/(auth)/action";
import { useUserDetailSheetHandlers } from "../handler";
import { IUserSchema } from "@/src/entities/models/users/users.model";
import { formatDate } from "@/app/_utils/common";
interface UserDetailSheetProps { interface UserDetailSheetProps {
open: boolean; open: boolean;
user: IUserSchema;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
user: any; onUserUpdated: () => void;
onUserUpdate: () => void;
} }
export function UserDetailSheet({ export function UserDetailSheet({
open, open,
onOpenChange, onOpenChange,
user, user,
onUserUpdate, onUserUpdated,
}: UserDetailSheetProps) { }: UserDetailSheetProps) {
const [isDeleting, setIsDeleting] = useState(false); // const [isDeleting, setIsDeleting] = useState(false);
const [isLoading, setIsLoading] = useState({ // const [isLoading, setIsLoading] = useState({
deleteUser: false, // deleteUser: false,
sendPasswordRecovery: false, // sendPasswordRecovery: false,
sendMagicLink: false, // sendMagicLink: false,
toggleBan: false, // toggleBan: false,
}); // });
const deleteUserMutation = useMutation({ // const deleteUserMutation = useMutation({
mutationFn: () => deleteUser(user.id), // mutationFn: () => deleteUser(user.id),
onMutate: () => { // onMutate: () => {
setIsLoading((prev) => ({ ...prev, deleteUser: true })); // setIsLoading((prev) => ({ ...prev, deleteUser: true }));
setIsDeleting(true); // setIsDeleting(true);
}, // },
onSuccess: () => { // onSuccess: () => {
toast.success("User deleted successfully"); // toast.success("User deleted successfully");
onUserUpdate(); // onUserUpdate();
onOpenChange(false); // onOpenChange(false);
}, // },
onError: () => { // onError: () => {
toast.error("Failed to delete user"); // toast.error("Failed to delete user");
}, // },
onSettled: () => { // onSettled: () => {
setIsLoading((prev) => ({ ...prev, deleteUser: false })); // setIsLoading((prev) => ({ ...prev, deleteUser: false }));
setIsDeleting(false); // setIsDeleting(false);
}, // },
}); // });
const sendPasswordRecoveryMutation = useMutation({ // const sendPasswordRecoveryMutation = useMutation({
mutationFn: () => { // mutationFn: () => {
if (!user.email) { // if (!user.email) {
throw new Error("User does not have an email address"); // throw new Error("User does not have an email address");
} // }
return sendPasswordRecovery(user.email); // return sendPasswordRecovery(user.email);
}, // },
onMutate: () => { // onMutate: () => {
setIsLoading((prev) => ({ ...prev, sendPasswordRecovery: true })); // setIsLoading((prev) => ({ ...prev, sendPasswordRecovery: true }));
}, // },
onSuccess: () => { // onSuccess: () => {
toast.success("Password recovery email sent"); // toast.success("Password recovery email sent");
}, // },
onError: () => { // onError: () => {
toast.error("Failed to send password recovery email"); // toast.error("Failed to send password recovery email");
}, // },
onSettled: () => { // onSettled: () => {
setIsLoading((prev) => ({ ...prev, sendPasswordRecovery: false })); // setIsLoading((prev) => ({ ...prev, sendPasswordRecovery: false }));
}, // },
}); // });
const sendMagicLinkMutation = useMutation({ // const sendMagicLinkMutation = useMutation({
mutationFn: () => { // mutationFn: () => {
if (!user.email) { // if (!user.email) {
throw new Error("User does not have an email address"); // throw new Error("User does not have an email address");
} // }
return sendMagicLink(user.email); // return sendMagicLink(user.email);
}, // },
onMutate: () => { // onMutate: () => {
setIsLoading((prev) => ({ ...prev, sendMagicLink: true })); // setIsLoading((prev) => ({ ...prev, sendMagicLink: true }));
}, // },
onSuccess: () => { // onSuccess: () => {
toast.success("Magic link sent successfully"); // toast.success("Magic link sent successfully");
}, // },
onError: () => { // onError: () => {
toast.error("Failed to send magic link"); // toast.error("Failed to send magic link");
}, // },
onSettled: () => { // onSettled: () => {
setIsLoading((prev) => ({ ...prev, sendMagicLink: false })); // setIsLoading((prev) => ({ ...prev, sendMagicLink: false }));
}, // },
}); // });
const toggleBanMutation = useMutation({ // const toggleBanMutation = useMutation({
mutationFn: () => { // mutationFn: () => {
if (user.banned_until) { // if (user.banned_until) {
return unbanUser(user.id); // return unbanUser(user.id);
} else { // } else {
const ban_duration = "7h"; // Example: Ban duration set to 7 days // const ban_duration = "7h"; // Example: Ban duration set to 7 days
return banUser(user.id, ban_duration); // return banUser({ id: user.id, ban_duration });
} // }
}, // },
onMutate: () => { // onMutate: () => {
setIsLoading((prev) => ({ ...prev, toggleBan: true })); // setIsLoading((prev) => ({ ...prev, toggleBan: true }));
}, // },
onSuccess: () => { // onSuccess: () => {
toast.success("User ban status updated"); // toast.success("User ban status updated");
onUserUpdate(); // onUserUpdate();
}, // },
onError: () => { // onError: () => {
toast.error("Failed to update user ban status"); // toast.error("Failed to update user ban status");
}, // },
onSettled: () => { // onSettled: () => {
setIsLoading((prev) => ({ ...prev, toggleBan: false })); // setIsLoading((prev) => ({ ...prev, toggleBan: false }));
}, // },
}); // });
const handleCopyItem = (item: string) => { // const handleCopyItem = (item: string) => {
navigator.clipboard.writeText(item); // navigator.clipboard.writeText(item);
toast.success("Copied to clipboard"); // toast.success("Copied to clipboard");
}; // };
const formatDate = (date: string | undefined | null) => { const {
return date ? format(new Date(date), "PPpp") : "-"; handleDeleteUser,
}; handleSendPasswordRecovery,
handleSendMagicLink,
handleCopyItem,
handleToggleBan,
isBanPending,
isUnbanPending,
isDeletePending,
isSendPasswordRecoveryPending,
isSendMagicLinkPending,
} = useUserDetailSheetHandlers({ open, user, onUserUpdated, onOpenChange });
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
@ -166,7 +178,7 @@ export function UserDetailSheet({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-4 w-4" className="h-4 w-4"
onClick={() => handleCopyItem(user.email)} onClick={() => handleCopyItem(user.email ?? "")}
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
@ -203,15 +215,13 @@ export function UserDetailSheet({
<div className="flex justify-between items-center py-1"> <div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">Created at</span> <span className="text-muted-foreground">Created at</span>
<span>{new Date(user.created_at).toLocaleString()}</span> <span>{formatDate(user.created_at)}</span>
</div> </div>
<div className="flex justify-between items-center py-1"> <div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">Updated at</span> <span className="text-muted-foreground">Updated at</span>
<span> <span>
{new Date( {formatDate(user.updated_at)}
user.updated_at || user.created_at
).toLocaleString()}
</span> </span>
</div> </div>
@ -224,7 +234,7 @@ export function UserDetailSheet({
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Confirmation sent at Confirmation sent at
</span> </span>
<span>{formatDate(user.email_confirmation_sent_at)}</span> <span>{formatDate(user.email_confirmed_at)}</span>
</div> </div>
<div className="flex justify-between items-center py-1"> <div className="flex justify-between items-center py-1">
@ -284,10 +294,10 @@ export function UserDetailSheet({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => sendPasswordRecoveryMutation.mutate()} onClick={handleSendPasswordRecovery}
disabled={isLoading.sendPasswordRecovery || !user.email} disabled={isSendPasswordRecoveryPending}
> >
{isLoading.sendPasswordRecovery ? ( {isSendPasswordRecoveryPending ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending... Sending...
@ -313,10 +323,10 @@ export function UserDetailSheet({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => sendMagicLinkMutation.mutate()} onClick={handleSendMagicLink}
disabled={isLoading.sendMagicLink || !user.email} disabled={isSendMagicLinkPending}
> >
{isLoading.sendMagicLink ? ( {isSendMagicLinkPending ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending... Sending...
@ -354,10 +364,10 @@ export function UserDetailSheet({
<Button <Button
variant={user.banned_until ? "outline" : "outline"} variant={user.banned_until ? "outline" : "outline"}
size="sm" size="sm"
onClick={() => toggleBanMutation.mutate()} onClick={handleToggleBan}
disabled={isLoading.toggleBan} disabled={isBanPending || isUnbanPending}
> >
{isLoading.toggleBan ? ( {isBanPending || isUnbanPending ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
{user.banned_until ? "Unbanning..." : "Banning..."} {user.banned_until ? "Unbanning..." : "Banning..."}
@ -383,9 +393,9 @@ export function UserDetailSheet({
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
disabled={isDeleting} disabled={isDeletePending}
> >
{isDeleting ? ( {isDeletePending ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting... Deleting...
@ -412,11 +422,11 @@ export function UserDetailSheet({
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => deleteUserMutation.mutate()} onClick={handleDeleteUser}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDeleting} disabled={isDeletePending}
> >
{isDeleting ? ( {isDeletePending ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting... Deleting...

View File

@ -18,78 +18,24 @@ import { useMutation } from "@tanstack/react-query"
import { updateUser } from "../action" import { updateUser } from "../action"
import { toast } from "sonner" import { toast } from "sonner"
import { UpdateUserSchema } from "@/src/entities/models/users/update-user.model" import { UpdateUserSchema } from "@/src/entities/models/users/update-user.model"
import { useUserProfileSheetHandlers } from "../handler"
type UserProfileFormValues = z.infer<typeof UpdateUserSchema> type UserProfileFormValues = z.infer<typeof UpdateUserSchema>
interface UserProfileSheetProps { interface UserProfileSheetProps {
open: boolean open: boolean
userData: IUserSchema
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
userData?: IUserSchema
onUserUpdated: () => void onUserUpdated: () => void
} }
export function UserProfileSheet({ open, onOpenChange, userData, onUserUpdated }: UserProfileSheetProps) { export function UserProfileSheet({ open, onOpenChange, userData, onUserUpdated }: UserProfileSheetProps) {
// Initialize form with user data
const form = useForm<UserProfileFormValues>({
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 { mutate: updateUserMutation, isPending } = useMutation({ const {
mutationKey: ["updateUser"], form,
mutationFn: (data: UserProfileFormValues) => { handleUpdateUser,
if (!userData?.id) { isUpdatePending,
throw new Error("User ID is required") } = useUserProfileSheetHandlers({ open, userData, onOpenChange, onUserUpdated })
}
return updateUser(userData.id, data)
},
onError: (error) => {
toast("Failed to update user")
onOpenChange(false)
},
onSuccess: () => {
toast("User updated")
onUserUpdated()
onOpenChange(false)
},
})
async function onSubmit(data: UserProfileFormValues) {
try {
await updateUserMutation(data)
} catch (error) {
console.error("Error saving user profile:", error)
}
}
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
@ -100,7 +46,7 @@ export function UserProfileSheet({ open, onOpenChange, userData, onUserUpdated }
</SheetHeader> </SheetHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(handleUpdateUser)} className="space-y-6">
{/* User Information Section */} {/* User Information Section */}
<FormSection <FormSection
title="User Information" title="User Information"
@ -113,6 +59,7 @@ export function UserProfileSheet({ open, onOpenChange, userData, onUserUpdated }
control={form.control} control={form.control}
placeholder="email@example.com" placeholder="email@example.com"
rows={4} rows={4}
disabled={true}
/> />
<FormFieldWrapper <FormFieldWrapper
name="phone" name="phone"
@ -120,6 +67,8 @@ export function UserProfileSheet({ open, onOpenChange, userData, onUserUpdated }
type="tel" type="tel"
control={form.control} control={form.control}
placeholder="+1234567890" placeholder="+1234567890"
disabled={true}
/> />
<FormFieldWrapper <FormFieldWrapper
name="role" name="role"
@ -265,14 +214,14 @@ export function UserProfileSheet({ open, onOpenChange, userData, onUserUpdated }
type="button" type="button"
variant="outline" variant="outline"
size="xs" size="xs"
onClick={() => !isPending && onOpenChange(false)} onClick={() => !isUpdatePending && onOpenChange(false)}
disabled={isPending} disabled={isUpdatePending}
> >
Cancel Cancel
</Button> </Button>
<Button size="xs" type="submit" disabled={isPending}> <Button size="xs" type="submit" disabled={isUpdatePending}>
{isPending && <Loader2 className="mr-1 h-4 w-4 animate-spin" />} {isUpdatePending && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
{isPending ? "Saving..." : "Save"} {isUpdatePending ? "Saving..." : "Save"}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -143,7 +143,7 @@ export default function UserManagement() {
user={detailUser} user={detailUser}
open={isSheetOpen} open={isSheetOpen}
onOpenChange={setIsSheetOpen} onOpenChange={setIsSheetOpen}
onUserUpdate={() => { }} onUserUpdated={() => { }}
/> />
)} )}
<AddUserDialog <AddUserDialog

View File

@ -16,6 +16,7 @@ import { Input } from "@/app/_components/ui/input"
import { Avatar } from "@/app/_components/ui/avatar" import { Avatar } from "@/app/_components/ui/avatar"
import Image from "next/image" import Image from "next/image"
import { Badge } from "@/app/_components/ui/badge" import { Badge } from "@/app/_components/ui/badge"
import { useUserDetailSheetHandlers, useUsersHandlers } from "../handler"
export type UserTableColumn = ColumnDef<IUserSchema, IUserSchema> export type UserTableColumn = ColumnDef<IUserSchema, IUserSchema>
@ -24,6 +25,13 @@ export const createUserColumns = (
setFilters: (filters: IUserFilterOptionsSchema) => void, setFilters: (filters: IUserFilterOptionsSchema) => void,
handleUserUpdate: (user: IUserSchema) => void, handleUserUpdate: (user: IUserSchema) => void,
): UserTableColumn[] => { ): UserTableColumn[] => {
const {
deleteUser,
banUser,
unbanUser,
} = useUsersHandlers();
return [ return [
{ {
id: "email", id: "email",
@ -313,16 +321,18 @@ export const createUserColumns = (
Update Update
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => deleteUser(row.original.id)}
/* handle delete */
}}
> >
<Trash2 className="h-4 w-4 mr-2 text-destructive" /> <Trash2 className="h-4 w-4 mr-2 text-destructive" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
/* handle ban */ if (row.original.banned_until != null) {
unbanUser(row.original.id)
} else {
banUser(row.original.id)
}
}} }}
> >
<ShieldAlert className="h-4 w-4 mr-2 text-yellow-500" /> <ShieldAlert className="h-4 w-4 mr-2 text-yellow-500" />

View File

@ -14,24 +14,26 @@ import { IBanDuration, IBanUserSchema, ICredentialsBanUserSchema } from '@/src/e
import { ICreateUserSchema } from '@/src/entities/models/users/create-user.model'; import { ICreateUserSchema } from '@/src/entities/models/users/create-user.model';
import { IUpdateUserSchema } from '@/src/entities/models/users/update-user.model'; import { IUpdateUserSchema } from '@/src/entities/models/users/update-user.model';
import { ICredentialsInviteUserSchema } from '@/src/entities/models/users/invite-user.model'; import { ICredentialsInviteUserSchema } from '@/src/entities/models/users/invite-user.model';
import { ICredentialGetUserByEmailSchema } from '@/src/entities/models/users/read-user.model'; import { ICredentialGetUserByEmailSchema, ICredentialGetUserByIdSchema, ICredentialGetUserByUsernameSchema } from '@/src/entities/models/users/read-user.model';
import { ICredentialsUnbanUserSchema } from '@/src/entities/models/users/unban-user.model';
export async function banUser(id: string, ban_duration: IBanDuration) { export async function banUser(credential: ICredentialsBanUserSchema, data: IBanUserSchema) {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'banUser', 'unbanUser',
{ recordResponse: true }, { recordResponse: true },
async () => { async () => {
try { try {
const banUserController = getInjection('IBanUserController'); const banUserController = getInjection('IBanUserController');
await banUserController({ id }, { ban_duration }); await banUserController({ id: credential.id }, { ban_duration: data.ban_duration });
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
if (err instanceof InputParseError) { if (err instanceof InputParseError) {
// return { // return {
// error: err.message, // error: err.message,
// }; // };
throw new InputParseError(err.message); throw new InputParseError(err.message);
} }
@ -40,7 +42,7 @@ export async function banUser(id: string, ban_duration: IBanDuration) {
// return { // return {
// error: 'Must be logged in to create a user.', // error: 'Must be logged in to create a user.',
// }; // };
throw new UnauthenticatedError('Must be logged in to ban a user.'); throw new UnauthenticatedError('Must be logged in to unban a user.');
} }
if (err instanceof AuthenticationError) { if (err instanceof AuthenticationError) {
@ -63,7 +65,8 @@ export async function banUser(id: string, ban_duration: IBanDuration) {
); );
} }
export async function unbanUser(id: string) {
export async function unbanUser(credential: ICredentialsUnbanUserSchema) {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'unbanUser', 'unbanUser',
@ -71,7 +74,7 @@ export async function unbanUser(id: string) {
async () => { async () => {
try { try {
const unbanUserController = getInjection('IUnbanUserController'); const unbanUserController = getInjection('IUnbanUserController');
await unbanUserController({ id }); await unbanUserController(credential);
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
@ -134,7 +137,7 @@ export async function getCurrentUser() {
); );
} }
export async function getUserById(id: string) { export async function getUserById(credential: ICredentialGetUserByIdSchema) {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'getUserById', 'getUserById',
@ -142,7 +145,7 @@ export async function getUserById(id: string) {
async () => { async () => {
try { try {
const getUserByIdController = getInjection('IGetUserByIdController'); const getUserByIdController = getInjection('IGetUserByIdController');
return await getUserByIdController({ id }); return await getUserByIdController(credential);
} catch (err) { } catch (err) {
@ -230,7 +233,7 @@ export async function getUserByEmail(credential: ICredentialGetUserByEmailSchema
); );
} }
export async function getUserByUsername(username: string) { export async function getUserByUsername(credential: ICredentialGetUserByUsernameSchema) {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'getUserByUsername', 'getUserByUsername',
@ -240,7 +243,7 @@ export async function getUserByUsername(username: string) {
const getUserByUsernameController = getInjection( const getUserByUsernameController = getInjection(
'IGetUserByUsernameController' 'IGetUserByUsernameController'
); );
return await getUserByUsernameController({ username }); return await getUserByUsernameController(credential);
} catch (err) { } catch (err) {

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useCreateUserMutation, useInviteUserMutation } from './queries'; import { useBanUserMutation, useCreateUserMutation, useDeleteUserMutation, useInviteUserMutation, useUnbanUserMutation, useUpdateUserMutation } from './queries';
import { IUserSchema, IUserFilterOptionsSchema } from '@/src/entities/models/users/users.model'; import { IUserSchema, IUserFilterOptionsSchema } from '@/src/entities/models/users/users.model';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { set } from 'date-fns'; import { set } from 'date-fns';
@ -8,6 +8,226 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { defaulIInviteUserSchemaValues, IInviteUserSchema, InviteUserSchema } from '@/src/entities/models/users/invite-user.model'; import { defaulIInviteUserSchemaValues, IInviteUserSchema, InviteUserSchema } from '@/src/entities/models/users/invite-user.model';
import { useQueryClient } from '@tanstack/react-query'; 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 }: { export const useAddUserDialogHandler = ({ onUserAdded, onOpenChange }: {
onUserAdded: () => void; onUserAdded: () => void;
@ -139,6 +359,150 @@ export const useInviteUserHandler = ({ onUserInvited, onOpenChange }: {
}; };
} }
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) => { export const useUserManagementHandlers = (refetch: () => void) => {
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [detailUser, setDetailUser] = useState<IUserSchema | null>(null) const [detailUser, setDetailUser] = useState<IUserSchema | null>(null)

View File

@ -13,11 +13,12 @@ import {
getUserByUsername getUserByUsername
} from "./action"; } from "./action";
import { IUserSchema } from "@/src/entities/models/users/users.model"; import { IUserSchema } from "@/src/entities/models/users/users.model";
import { IBanUserSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model"; import { IBanDuration, IBanUserSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model";
import { IUnbanUserSchema } from "@/src/entities/models/users/unban-user.model"; import { ICredentialsUnbanUserSchema, IUnbanUserSchema } from "@/src/entities/models/users/unban-user.model";
import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model"; import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model";
import { IUpdateUserSchema } from "@/src/entities/models/users/update-user.model"; import { IUpdateUserSchema } from "@/src/entities/models/users/update-user.model";
import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model"; import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model";
import { ICredentialGetUserByEmailSchema, ICredentialGetUserByIdSchema, ICredentialGetUserByUsernameSchema, IGetUserByEmailSchema, IGetUserByIdSchema, IGetUserByUsernameSchema } from "@/src/entities/models/users/read-user.model";
const useUsersAction = () => { const useUsersAction = () => {
@ -28,35 +29,35 @@ const useUsersAction = () => {
}); });
// Current user query doesn't need parameters // Current user query doesn't need parameters
const getCurrentUserQuery = useQuery({ const getCurrentUserQuery = useQuery<IUserSchema>({
queryKey: ["user", "current"], queryKey: ["user", "current"],
queryFn: async () => await getCurrentUser() queryFn: async () => await getCurrentUser()
}); });
const getUserByIdQuery = (id: string) => ({ const getUserByIdQuery = (credential: ICredentialGetUserByIdSchema) => useQuery<IUserSchema>({
queryKey: ["user", id], queryKey: ["user", "id", credential.id],
queryFn: async () => await getUserById(id) queryFn: async () => await getUserById(credential)
}); });
const getUserByEmailQuery = (email: string) => ({ const getUserByEmailQuery = (credential: IGetUserByEmailSchema) => useQuery<IUserSchema>({
queryKey: ["user", "email", email], queryKey: ["user", "email", credential.email],
queryFn: async () => await getUserByEmail({ email }) queryFn: async () => await getUserByEmail(credential)
}); });
const getUserByUsernameQuery = (username: string) => ({ const getUserByUsernameQuery = (credential: IGetUserByUsernameSchema) => useQuery<IUserSchema>({
queryKey: ["user", "username", username], queryKey: ["user", "username", credential.username],
queryFn: async () => await getUserByUsername(username) queryFn: async () => await getUserByUsername(credential)
}); });
// Mutations that don't need dynamic parameters // Mutations that don't need dynamic parameters
const banUserMutation = useMutation({ const banUserMutation = useMutation({
mutationKey: ["banUser"], mutationKey: ["banUser"],
mutationFn: async ({ credential, params }: { credential: ICredentialsBanUserSchema; params: IBanUserSchema }) => await banUser(credential.id, params.ban_duration) mutationFn: async ({ credential, data }: { credential: ICredentialsBanUserSchema; data: IBanUserSchema }) => await banUser(credential, data)
}); });
const unbanUserMutation = useMutation({ const unbanUserMutation = useMutation({
mutationKey: ["unbanUser"], mutationKey: ["unbanUser"],
mutationFn: async (params: IUnbanUserSchema) => await unbanUser(params.id) mutationFn: async (credential: ICredentialsUnbanUserSchema) => await unbanUser(credential)
}); });
// Create functions that return configured hooks // Create functions that return configured hooks
@ -117,6 +118,39 @@ export const useGetCurrentUserQuery = () => {
}; };
} }
export const useGetUserByIdQuery = (credential: ICredentialGetUserByIdSchema) => {
const { getUserById } = useUsersAction();
return {
data: getUserById(credential).data,
isPending: getUserById(credential).isPending,
error: getUserById(credential).error,
refetch: getUserById(credential).refetch,
};
}
export const useGetUserByEmailQuery = (credential: ICredentialGetUserByEmailSchema) => {
const { getUserByEmailQuery } = useUsersAction();
return {
data: getUserByEmailQuery(credential).data,
isPending: getUserByEmailQuery(credential).isPending,
error: getUserByEmailQuery(credential).error,
refetch: getUserByEmailQuery(credential).refetch,
};
}
export const useGetUserByUsernameQuery = (credential: ICredentialGetUserByUsernameSchema) => {
const { getUserByUsernameQuery } = useUsersAction();
return {
data: getUserByUsernameQuery(credential).data,
isPending: getUserByUsernameQuery(credential).isPending,
error: getUserByUsernameQuery(credential).error,
refetch: getUserByUsernameQuery(credential).refetch,
};
}
export const useCreateUserMutation = () => { export const useCreateUserMutation = () => {
const { createUser } = useUsersAction(); const { createUser } = useUsersAction();
@ -136,3 +170,43 @@ export const useInviteUserMutation = () => {
errors: inviteUser.error, errors: inviteUser.error,
} }
} }
export const useUpdateUserMutation = () => {
const { updateUser } = useUsersAction();
return {
updateUser: updateUser.mutateAsync,
isPending: updateUser.isPending,
errors: updateUser.error,
}
}
export const useBanUserMutation = () => {
const { banUser } = useUsersAction();
return {
banUser: banUser.mutateAsync,
isPending: banUser.isPending,
errors: banUser.error,
}
}
export const useUnbanUserMutation = () => {
const { unbanUser } = useUsersAction();
return {
unbanUser: unbanUser.mutateAsync,
isPending: unbanUser.isPending,
errors: unbanUser.error,
}
}
export const useDeleteUserMutation = () => {
const { deleteUser } = useUsersAction();
return {
deleteUser: deleteUser.mutateAsync,
isPending: deleteUser.isPending,
errors: deleteUser.error,
}
}

View File

@ -134,13 +134,12 @@ export async function verifyOtp(formData: FormData) {
}) })
} }
export async function sendMagicLink(formData: FormData) { export async function sendMagicLink(email: string) {
const instrumentationService = getInjection("IInstrumentationService") const instrumentationService = getInjection("IInstrumentationService")
return await instrumentationService.instrumentServerAction("sendMagicLink", { return await instrumentationService.instrumentServerAction("sendMagicLink", {
recordResponse: true recordResponse: true
}, async () => { }, async () => {
try { try {
const email = formData.get("email")?.toString()
const sendMagicLinkController = getInjection("ISendMagicLinkController") const sendMagicLinkController = getInjection("ISendMagicLinkController")
await sendMagicLinkController({ email }) await sendMagicLinkController({ email })
@ -161,13 +160,12 @@ export async function sendMagicLink(formData: FormData) {
}) })
} }
export async function sendPasswordRecovery(formData: FormData) { export async function sendPasswordRecovery(email: string) {
const instrumentationService = getInjection("IInstrumentationService") const instrumentationService = getInjection("IInstrumentationService")
return await instrumentationService.instrumentServerAction("sendPasswordRecovery", { return await instrumentationService.instrumentServerAction("sendPasswordRecovery", {
recordResponse: true recordResponse: true
}, async () => { }, async () => {
try { try {
const email = formData.get("email")?.toString()
const sendPasswordRecoveryController = getInjection("ISendPasswordRecoveryController") const sendPasswordRecoveryController = getInjection("ISendPasswordRecoveryController")
await sendPasswordRecoveryController({ email }) await sendPasswordRecoveryController({ email })

View File

@ -21,12 +21,12 @@ export function useAuthActions() {
const sendMagicLinkMutation = useMutation({ const sendMagicLinkMutation = useMutation({
mutationKey: ["sendMagicLink"], mutationKey: ["sendMagicLink"],
mutationFn: async (formData: FormData) => await sendMagicLink(formData) mutationFn: async (email: string) => await sendMagicLink(email)
}); });
const sendPasswordRecoveryMutation = useMutation({ const sendPasswordRecoveryMutation = useMutation({
mutationKey: ["sendPasswordRecovery"], mutationKey: ["sendPasswordRecovery"],
mutationFn: async (formData: FormData) => await sendPasswordRecovery(formData) mutationFn: async (email: string) => await sendPasswordRecovery(email)
}); });
return { return {
@ -36,4 +36,54 @@ export function useAuthActions() {
sendMagicLink: sendMagicLinkMutation, sendMagicLink: sendMagicLinkMutation,
sendPasswordRecovery: sendPasswordRecoveryMutation sendPasswordRecovery: sendPasswordRecoveryMutation
}; };
} }
export const useSignInMutation = () => {
const { signIn } = useAuthActions();
return {
signIn: signIn.mutateAsync,
isPending: signIn.isPending,
error: signIn.error,
};
}
export const useVerifyOtpMutation = () => {
const { verifyOtp } = useAuthActions();
return {
verifyOtp: verifyOtp.mutateAsync,
isPending: verifyOtp.isPending,
error: verifyOtp.error,
}
}
export const useSignOutMutation = () => {
const { signOut } = useAuthActions();
return {
signOut: signOut.mutateAsync,
isPending: signOut.isPending
}
}
export const useSendMagicLinkMutation = () => {
const { sendMagicLink } = useAuthActions();
return {
sendMagicLink: sendMagicLink.mutateAsync,
isPending: sendMagicLink.isPending,
error: sendMagicLink.error,
}
}
export const useSendPasswordRecoveryMutation = () => {
const { sendPasswordRecovery } = useAuthActions();
return {
sendPasswordRecovery: sendPasswordRecovery.mutateAsync,
isPending: sendPasswordRecovery.isPending,
error: sendPasswordRecovery.error,
}
}

View File

@ -54,7 +54,7 @@ export default function RootLayout({
</nav> */} </nav> */}
<div className="flex flex-col max-w-full p-0"> <div className="flex flex-col max-w-full p-0">
{children} {children}
<Toaster theme="system" richColors position="top-right" /> <Toaster theme="system" position="top-right" />
</div> </div>
{/* <footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16 mt-auto"> {/* <footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16 mt-auto">

View File

@ -31,6 +31,7 @@ interface FormFieldProps {
booleanType?: "switch" | "checkbox" | "select" booleanType?: "switch" | "checkbox" | "select"
fromYear?: number fromYear?: number
toYear?: number toYear?: number
disabled?: boolean // Ganti readonly menjadi disabled
} }
export function FormFieldWrapper({ export function FormFieldWrapper({
@ -47,6 +48,7 @@ export function FormFieldWrapper({
booleanType = "switch", booleanType = "switch",
fromYear = 1900, fromYear = 1900,
toYear = new Date().getFullYear(), toYear = new Date().getFullYear(),
disabled = false, // Default disabled adalah false
}: FormFieldProps) { }: FormFieldProps) {
// Default boolean options for select // Default boolean options for select
const booleanOptions = [ const booleanOptions = [
@ -60,11 +62,12 @@ export function FormFieldWrapper({
name={name} name={name}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
className={ className={cn(
isBoolean && booleanType === "switch" isBoolean && booleanType === "switch"
? "flex items-start justify-between rounded-lg border p-4" ? "flex items-start justify-between rounded-lg border p-4"
: "flex justify-between items-start gap-4" : "flex justify-between items-start gap-4",
} disabled && "opacity-50 cursor-not-allowed" // Tambahkan gaya khusus untuk disabled
)}
> >
<div className={isBoolean && booleanType === "switch" ? "space-y-1" : "w-1/3"}> <div className={isBoolean && booleanType === "switch" ? "space-y-1" : "w-1/3"}>
<FormLabel className={isBoolean && booleanType === "switch" ? "text-base" : ""}>{label}</FormLabel> <FormLabel className={isBoolean && booleanType === "switch" ? "text-base" : ""}>{label}</FormLabel>
@ -75,13 +78,14 @@ export function FormFieldWrapper({
<FormControl> <FormControl>
{isBoolean ? ( {isBoolean ? (
booleanType === "switch" ? ( booleanType === "switch" ? (
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch checked={field.value} onCheckedChange={field.onChange} disabled={disabled} />
) : booleanType === "checkbox" ? ( ) : booleanType === "checkbox" ? (
<Checkbox checked={field.value} onCheckedChange={field.onChange} /> <Checkbox checked={field.value} onCheckedChange={field.onChange} disabled={disabled} />
) : ( ) : (
<Select <Select
onValueChange={(value) => field.onChange(value === "true")} onValueChange={(value) => field.onChange(value === "true")}
defaultValue={String(field.value)} defaultValue={String(field.value)}
disabled={disabled}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={placeholder || `Select ${label.toLowerCase()}`} /> <SelectValue placeholder={placeholder || `Select ${label.toLowerCase()}`} />
@ -96,7 +100,7 @@ export function FormFieldWrapper({
</Select> </Select>
) )
) : type.includes("select") && options ? ( ) : type.includes("select") && options ? (
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={placeholder || `Select ${label.toLowerCase()}`} /> <SelectValue placeholder={placeholder || `Select ${label.toLowerCase()}`} />
</SelectTrigger> </SelectTrigger>
@ -113,23 +117,28 @@ export function FormFieldWrapper({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className={cn("w-full pl-3 text-left font-normal", !field.value && "text-muted-foreground")} className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
disabled={disabled}
> >
{field.value ? format(field.value, "MM/dd/yyyy hh:mm:ss a") : <span>Pick a date and time</span>} {field.value ? format(field.value, "MM/dd/yyyy hh:mm:ss a") : <span>Pick a date and time</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> {!disabled && (
<DateTimePicker <PopoverContent className="w-auto p-0" align="start">
selected={field.value instanceof Date ? field.value : undefined} <DateTimePicker
onSelect={field.onChange} selected={field.value instanceof Date ? field.value : undefined}
disabled={(date) => date > new Date() || date < new Date(`${fromYear}-01-01`)} onSelect={field.onChange}
fromYear={fromYear} disabled={(date) => date > new Date() || date < new Date(`${fromYear}-01-01`)}
toYear={toYear} fromYear={fromYear}
showTimePicker toYear={toYear}
/> showTimePicker
/>
</PopoverContent> </PopoverContent>
)}
</Popover> </Popover>
) : rows > 1 ? ( ) : rows > 1 ? (
<Textarea <Textarea
@ -138,6 +147,7 @@ export function FormFieldWrapper({
rows={rows} rows={rows}
value={field.value ?? ""} value={field.value ?? ""}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled}
/> />
) : ( ) : (
<Input <Input
@ -145,6 +155,7 @@ export function FormFieldWrapper({
type={type.includes("URL") ? "url" : type === "string" ? "text" : type} type={type.includes("URL") ? "url" : type === "string" ? "text" : type}
placeholder={placeholder} placeholder={placeholder}
value={field.value ?? ""} value={field.value ?? ""}
disabled={disabled}
/> />
)} )}
</FormControl> </FormControl>
@ -155,4 +166,3 @@ export function FormFieldWrapper({
/> />
) )
} }

View File

@ -730,7 +730,7 @@ const SidebarMenuSubButton = React.forwardRef<
className className
)} )}
{...props} {...props}
/> />
); );
}); });
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"; SidebarMenuSubButton.displayName = "SidebarMenuSubButton";

View File

@ -0,0 +1,74 @@
import { Locale } from "date-fns";
/**
* Available date format patterns for the formatDate function.
* See date-fns format documentation for more details on each pattern.
*/
export type DateFormatPattern =
// Year formats
| "yyyy" // 2025
| "yy" // 25
| "y" // 2025
// Month formats
| "MMMM" // March
| "MMM" // Mar
| "MM" // 03
| "M" // 3
// Day formats
| "dd" // 01-31
| "d" // 1-31
| "Do" // 1st, 2nd, etc.
| "EEEE" // Monday, Tuesday, etc.
| "EEE" // Mon, Tue, etc.
| "EE" // Mo, Tu, etc.
| "E" // M, T, etc.
// Hour formats
| "HH" // 00-23
| "H" // 0-23
| "hh" // 01-12
| "h" // 1-12
// Minute and second formats
| "mm" // 00-59
| "m" // 0-59
| "ss" // 00-59
| "s" // 0-59
// AM/PM
| "a" // am/pm
| "aa" // AM/PM
// Combined common formats
| "PPpp" // Mar 23, 2025, 12:00 AM
| "Pp" // 03/23/2025, 12:00 AM
| "PP" // Mar 23, 2025
| "p" // 12:00 AM
// Custom combined patterns (allow any string)
| string;
/**
* Custom formatting options for dates.
*/
export interface DateFormatOptions {
/**
* The format pattern to use when formatting the date.
* @default "PPpp"
*/
format?: DateFormatPattern;
/**
* The value to display when the date is null or undefined.
* @default "-"
*/
fallback?: string;
/**
* The locale to use for formatting.
* @default undefined (uses system locale)
*/
locale?: Locale;
}

View File

@ -0,0 +1,5 @@
export interface ServerActionErrorParams {
message: string;
code: string;
field?: string;
}

View File

@ -1,4 +1,6 @@
import { format } from "date-fns";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { DateFormatOptions, DateFormatPattern } from "../_lib/types/date-format.interface";
/** /**
* Redirects to a specified path with an encoded message as a query parameter. * Redirects to a specified path with an encoded message as a query parameter.
@ -70,4 +72,49 @@ export function generateUsername(email: string): string {
const [localPart] = email.split("@"); const [localPart] = email.split("@");
const randomSuffix = Math.random().toString(36).substring(2, 8); // Generate a random alphanumeric string const randomSuffix = Math.random().toString(36).substring(2, 8); // Generate a random alphanumeric string
return `${localPart}.${randomSuffix}`; return `${localPart}.${randomSuffix}`;
} }
/**
* Formats a date string to a human-readable format with type safety.
* @param date - The date string to format.
* @param options - Formatting options or a format string.
* @returns The formatted date string.
* @example
* // Using default format
* formatDate("2025-03-23")
*
* // Using a custom format string
* formatDate("2025-03-23", "yyyy-MM-dd")
*
* // Using formatting options
* formatDate("2025-03-23", { format: "MMMM do, yyyy", fallback: "Not available" })
*/
export const formatDate = (
date: string | Date | undefined | null,
options: DateFormatOptions | DateFormatPattern = { format: "PPpp" }
): string => {
if (!date) {
return typeof options === "string"
? "-"
: (options.fallback || "-");
}
const dateObj = date instanceof Date ? date : new Date(date);
// Handle invalid dates
if (isNaN(dateObj.getTime())) {
return typeof options === "string"
? "-"
: (options.fallback || "-");
}
if (typeof options === "string") {
return format(dateObj, options);
}
const { format: formatPattern = "PPpp", locale } = options;
return locale
? format(dateObj, formatPattern, { locale })
: format(dateObj, formatPattern);
};

View File

@ -51,14 +51,14 @@ export function createUsersModule() {
.bind(DI_SYMBOLS.IBanUserUseCase) .bind(DI_SYMBOLS.IBanUserUseCase)
.toHigherOrderFunction(banUserUseCase, [ .toHigherOrderFunction(banUserUseCase, [
DI_SYMBOLS.IInstrumentationService, DI_SYMBOLS.IInstrumentationService,
DI_SYMBOLS.IUsersRepository DI_SYMBOLS.IUsersRepository,
]); ]);
usersModule usersModule
.bind(DI_SYMBOLS.IUnbanUserUseCase) .bind(DI_SYMBOLS.IUnbanUserUseCase)
.toHigherOrderFunction(unbanUserUseCase, [ .toHigherOrderFunction(unbanUserUseCase, [
DI_SYMBOLS.IInstrumentationService, DI_SYMBOLS.IInstrumentationService,
DI_SYMBOLS.IUsersRepository DI_SYMBOLS.IUsersRepository,
]); ]);
usersModule usersModule
@ -129,14 +129,16 @@ export function createUsersModule() {
.bind(DI_SYMBOLS.IBanUserController) .bind(DI_SYMBOLS.IBanUserController)
.toHigherOrderFunction(banUserController, [ .toHigherOrderFunction(banUserController, [
DI_SYMBOLS.IInstrumentationService, DI_SYMBOLS.IInstrumentationService,
DI_SYMBOLS.IBanUserUseCase DI_SYMBOLS.IBanUserUseCase,
DI_SYMBOLS.IGetCurrentUserUseCase
]); ]);
usersModule usersModule
.bind(DI_SYMBOLS.IUnbanUserController) .bind(DI_SYMBOLS.IUnbanUserController)
.toHigherOrderFunction(unbanUserController, [ .toHigherOrderFunction(unbanUserController, [
DI_SYMBOLS.IInstrumentationService, DI_SYMBOLS.IInstrumentationService,
DI_SYMBOLS.IUnbanUserUseCase DI_SYMBOLS.IUnbanUserUseCase,
DI_SYMBOLS.IGetCurrentUserUseCase
]); ]);
usersModule usersModule
@ -194,14 +196,16 @@ export function createUsersModule() {
.bind(DI_SYMBOLS.IUpdateUserController) .bind(DI_SYMBOLS.IUpdateUserController)
.toHigherOrderFunction(updateUserController, [ .toHigherOrderFunction(updateUserController, [
DI_SYMBOLS.IInstrumentationService, DI_SYMBOLS.IInstrumentationService,
DI_SYMBOLS.IUpdateUserUseCase DI_SYMBOLS.IUpdateUserUseCase,
DI_SYMBOLS.IGetCurrentUserUseCase
]); ]);
usersModule usersModule
.bind(DI_SYMBOLS.IDeleteUserController) .bind(DI_SYMBOLS.IDeleteUserController)
.toHigherOrderFunction(deleteUserController, [ .toHigherOrderFunction(deleteUserController, [
DI_SYMBOLS.IInstrumentationService, DI_SYMBOLS.IInstrumentationService,
DI_SYMBOLS.IDeleteUserUseCase DI_SYMBOLS.IDeleteUserUseCase,
DI_SYMBOLS.IGetCurrentUserUseCase
]); ]);

View File

@ -13,13 +13,13 @@ export const sendMagicLinkUseCase = (
) => async (input: { email: string }): Promise<void> => { ) => async (input: { email: string }): Promise<void> => {
return await instrumentationService.startSpan({ name: "sendMagicLink Use Case", op: "function" }, return await instrumentationService.startSpan({ name: "sendMagicLink Use Case", op: "function" },
async () => { async () => {
const user = await usersRepository.getUserByEmail(input.email) const user = await usersRepository.getUserByEmail({ email: input.email })
if (!user) { if (!user) {
throw new NotFoundError("User not found") throw new NotFoundError("User not found")
} }
await authenticationService.sendMagicLink(input.email) await authenticationService.sendMagicLink({ email: input.email })
} }
) )
} }

View File

@ -13,13 +13,13 @@ export const sendPasswordRecoveryUseCase = (
) => async (input: { email: string }): Promise<void> => { ) => async (input: { email: string }): Promise<void> => {
return await instrumentationService.startSpan({ name: "sendPasswordRecovery Use Case", op: "function" }, return await instrumentationService.startSpan({ name: "sendPasswordRecovery Use Case", op: "function" },
async () => { async () => {
const user = await usersRepository.getUserByEmail(input.email) const user = await usersRepository.getUserByEmail({ email: input.email })
if (!user) { if (!user) {
throw new NotFoundError("User not found") throw new NotFoundError("User not found")
} }
await authenticationService.sendPasswordRecovery(input.email) await authenticationService.sendPasswordRecovery({ email: input.email })
} }
) )
} }

View File

@ -1,3 +1,5 @@
import { ServerActionErrorParams } from "@/app/_lib/types/error-server-action.interface";
export class DatabaseOperationError extends Error { export class DatabaseOperationError extends Error {
constructor(message: string, options?: ErrorOptions) { constructor(message: string, options?: ErrorOptions) {
super(message, options); super(message, options);
@ -16,12 +18,15 @@ export class InputParseError extends Error {
} }
} }
export class ServerActionError extends Error { export class ServerActionError extends Error {
code: string; code: string;
field?: string;
constructor(message: string, code: string) { constructor({ message, code, field }: ServerActionErrorParams) {
super(message); super(message);
this.name = "ServerActionError"; this.name = "ServerActionError";
this.code = code; this.code = code;
this.field = field;
} }
} }

View File

@ -30,31 +30,39 @@ export const UpdateUserSchema = z.object({
export type IUpdateUserSchema = z.infer<typeof UpdateUserSchema>; export type IUpdateUserSchema = z.infer<typeof UpdateUserSchema>;
export const defaulIUpdateUserSchemaValues: IUpdateUserSchema = { export const defaultValueUpdateUserSchema: IUpdateUserSchema = {
email: "", email: undefined,
email_confirmed_at: false, email_confirmed_at: undefined,
encrypted_password: "", encrypted_password: undefined,
role: "user", role: "user",
phone: "", phone: undefined,
phone_confirmed_at: false, phone_confirmed_at: undefined,
invited_at: "", invited_at: undefined,
confirmed_at: "", confirmed_at: undefined,
last_sign_in_at: "", last_sign_in_at: undefined,
created_at: "", created_at: undefined,
updated_at: "", updated_at: undefined,
is_anonymous: false, is_anonymous: false,
user_metadata: {}, user_metadata: {},
app_metadata: {}, app_metadata: {},
profile: { profile: {
avatar: "", avatar: undefined,
username: "", username: undefined,
first_name: "", first_name: undefined,
last_name: "", last_name: undefined,
bio: "", bio: undefined,
address: "", address: {
birth_date: new Date(), street: "",
city: "",
state: "",
country: "",
postal_code: "",
},
birth_date: undefined,
} }
} };
export const CredentialUpdateUserSchema = z.object({ export const CredentialUpdateUserSchema = z.object({
id: z.string(), id: z.string(),

View File

@ -20,7 +20,7 @@ export const deleteUserController =
const session = await getCurrentUserUseCase() const session = await getCurrentUserUseCase()
if (!session) { if (!session) {
throw new UnauthenticatedError("Must be logged in to create a user") throw new UnauthenticatedError("Must be logged in to delete a user")
} }
return await deleteUserUseCase(credential); return await deleteUserUseCase(credential);