From 0af8a9be0b924e9445c3ac5451303789354bfbc1 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Sat, 22 Mar 2025 23:20:31 +0700 Subject: [PATCH] refactor add user and invite user --- .../(admin)/_components/app-sidebar.tsx | 36 +- .../_components/navigations/nav-user.tsx | 4 +- .../_components/settings/profile-settings.tsx | 11 +- .../app/(pages)/(admin)/dashboard/page.tsx | 18 +- .../_components/add-user-dialog.tsx | 176 +--- .../_components/invite-user.tsx | 97 +-- .../_components/profile-form.tsx | 4 +- .../user-management/_components/sheet.tsx | 6 +- .../_components/update-user.tsx | 9 +- .../user-management/_components/user-form.tsx | 147 ---- .../_components/user-management.tsx | 591 +------------ .../_components/user-stats.tsx | 53 +- .../_components/users-table.tsx | 338 ++++++++ .../dashboard/user-management/action.ts | 809 ++++++++++-------- .../dashboard/user-management/handler.tsx | 308 +++++++ .../dashboard/user-management/queries.ts | 138 +++ sigap-website/app/(pages)/(auth)/action.ts | 75 +- sigap-website/app/(pages)/(auth)/handler.tsx | 171 ++-- sigap-website/app/(pages)/(auth)/mutation.ts | 47 - sigap-website/app/(pages)/(auth)/queries.ts | 39 + sigap-website/app/_components/header-auth.tsx | 134 +-- .../app/_components/react-hook-form-field.tsx | 40 + sigap-website/app/_components/ui/input.tsx | 2 +- sigap-website/app/_lib/const/number.ts | 4 + sigap-website/app/_lib/const/regex.ts | 4 + sigap-website/app/_lib/const/string.ts | 4 + sigap-website/app/_lib/types/ban-duration.ts | 11 + sigap-website/app/_styles/globals.css | 191 +++-- sigap-website/app/_utils/common.ts | 22 + sigap-website/app/_utils/validation.ts | 19 + .../di/modules/authentication.module.ts | 34 + sigap-website/di/modules/users.module.ts | 11 +- sigap-website/di/types.ts | 19 +- sigap-website/prisma/db.ts | 46 +- .../repositories/authentication.repository.ts | 173 ---- .../users.repository.interface.ts | 755 +--------------- .../authentication.service.interface.ts | 24 +- .../use-cases/auth/sign-in.use-case.ts | 6 +- .../use-cases/auth/sign-up.use-case.ts | 14 +- .../use-cases/auth/verify-otp.use-case.ts | 4 +- .../use-cases/users/ban-user.use-case.ts | 11 +- .../use-cases/users/create-user.use-case.ts | 7 +- .../use-cases/users/delete-user.use-case.ts | 9 +- .../users/get-current-user.use-case.ts | 4 +- .../users/get-user-by-email.use-case.ts | 7 +- .../users/get-user-by-id.use-case.ts | 7 +- .../users/get-user-by-username.use-case.ts | 7 +- .../use-cases/users/get-users.use-case.ts | 2 +- .../use-cases/users/invite-user.use-case.ts | 10 +- .../use-cases/users/unban-user.use-case.ts | 9 +- .../use-cases/users/update-user.use-case.ts | 9 +- sigap-website/src/entities/errors/common.ts | 37 +- .../models/auth/send-magic-link.model.ts | 11 + .../auth/send-password-recovery.model.ts | 11 + .../src/entities/models/auth/sign-in.model.ts | 20 +- .../src/entities/models/auth/sign-up.model.ts | 28 +- .../entities/models/auth/verify-otp.model.ts | 4 +- .../entities/models/users/ban-user.model.ts | 30 + .../models/users/create-user.model.ts | 32 + .../models/users/delete-user.model.ts | 15 + .../models/users/invite-user.model.ts | 15 + .../entities/models/users/read-user.model.ts | 61 ++ .../entities/models/users/unban-user.model.ts | 15 + .../models/users/update-user.model.ts | 63 ++ .../src/entities/models/users/users.model.ts | 65 +- .../repositories/users.repository.ts | 91 +- .../services/authentication.service.ts | 32 +- .../controllers/auth/auth-controller.tsx | 123 --- .../auth/send-magic-link.controller.ts | 32 + .../auth/send-password-recovery.controller.ts | 30 + .../controllers/auth/sign-in.controller.tsx | 4 +- .../controllers/users/ban-user.controller.ts | 21 +- .../users/create-user.controller.ts | 38 +- .../users/delete-user.controller.ts | 13 +- .../users/get-user-by-email.controller.ts | 2 +- .../users/get-user-by-id.controller.ts | 2 +- .../users/get-user-by-username.controller.ts | 2 +- .../controllers/users/get-users.controller.ts | 9 +- .../users/invite-user.controller.ts | 13 +- .../users/unban-user.controller.ts | 13 +- .../users/update-user-controller.ts | 10 +- 81 files changed, 2629 insertions(+), 2889 deletions(-) delete mode 100644 sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-form.tsx create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/users-table.tsx create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/user-management/handler.tsx create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/user-management/queries.ts delete mode 100644 sigap-website/app/(pages)/(auth)/mutation.ts create mode 100644 sigap-website/app/(pages)/(auth)/queries.ts create mode 100644 sigap-website/app/_components/react-hook-form-field.tsx create mode 100644 sigap-website/app/_lib/const/regex.ts create mode 100644 sigap-website/app/_lib/types/ban-duration.ts create mode 100644 sigap-website/app/_utils/validation.ts delete mode 100644 sigap-website/src/application/repositories/authentication.repository.ts create mode 100644 sigap-website/src/entities/models/auth/send-magic-link.model.ts create mode 100644 sigap-website/src/entities/models/auth/send-password-recovery.model.ts create mode 100644 sigap-website/src/entities/models/users/ban-user.model.ts create mode 100644 sigap-website/src/entities/models/users/create-user.model.ts create mode 100644 sigap-website/src/entities/models/users/delete-user.model.ts create mode 100644 sigap-website/src/entities/models/users/invite-user.model.ts create mode 100644 sigap-website/src/entities/models/users/read-user.model.ts create mode 100644 sigap-website/src/entities/models/users/unban-user.model.ts create mode 100644 sigap-website/src/entities/models/users/update-user.model.ts delete mode 100644 sigap-website/src/interface-adapters/controllers/auth/auth-controller.tsx create mode 100644 sigap-website/src/interface-adapters/controllers/auth/send-magic-link.controller.ts create mode 100644 sigap-website/src/interface-adapters/controllers/auth/send-password-recovery.controller.ts diff --git a/sigap-website/app/(pages)/(admin)/_components/app-sidebar.tsx b/sigap-website/app/(pages)/(admin)/_components/app-sidebar.tsx index 0cbbf5e..a2ccd46 100644 --- a/sigap-website/app/(pages)/(admin)/_components/app-sidebar.tsx +++ b/sigap-website/app/(pages)/(admin)/_components/app-sidebar.tsx @@ -16,29 +16,27 @@ import { import { NavPreMain } from "./navigations/nav-pre-main"; import { navData } from "@/prisma/data/nav"; import { TeamSwitcher } from "../../../_components/team-switcher"; +import { useGetCurrentUserQuery } from "../dashboard/user-management/queries"; -import { Profile, User } from "@/src/entities/models/users/users.model"; -import { getCurrentUser } from "@/app/(pages)/(admin)/dashboard/user-management/action"; export function AppSidebar({ ...props }: React.ComponentProps) { - const [user, setUser] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(true); + const { data: user, isPending, error } = useGetCurrentUserQuery() - React.useEffect(() => { - async function fetchUser() { - try { - setIsLoading(true); - const userData = await getCurrentUser(); - setUser(userData.data.user); - } catch (error) { - console.error("Failed to fetch user:", error); - } finally { - setIsLoading(false); - } - } + // React.useEffect(() => { + // async function fetchUser() { + // try { + // setIsLoading(true); + // const userData = await getCurrentUser(); + // setUser(userData.data.user); + // } catch (error) { + // console.error("Failed to fetch user:", error); + // } finally { + // setIsLoading(false); + // } + // } - fetchUser(); - }, []); + // fetchUser(); + // }, []); return ( @@ -51,7 +49,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + diff --git a/sigap-website/app/(pages)/(admin)/_components/navigations/nav-user.tsx b/sigap-website/app/(pages)/(admin)/_components/navigations/nav-user.tsx index e8b01f8..7d51026 100644 --- a/sigap-website/app/(pages)/(admin)/_components/navigations/nav-user.tsx +++ b/sigap-website/app/(pages)/(admin)/_components/navigations/nav-user.tsx @@ -24,13 +24,13 @@ import { useSidebar, } from "@/app/_components/ui/sidebar"; import { IconLogout, IconSettings, IconSparkles } from "@tabler/icons-react"; -import type { User } from "@/src/entities/models/users/users.model"; +import type { IUserSchema } from "@/src/entities/models/users/users.model"; // import { signOut } from "@/app/(pages)/(auth)/action"; import { SettingsDialog } from "../settings/setting-dialog"; import { useSignOutHandler } from "@/app/(pages)/(auth)/handler"; import { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogCancel, AlertDialogAction } from "@/app/_components/ui/alert-dialog"; -export function NavUser({ user }: { user: User | null }) { +export function NavUser({ user }: { user: IUserSchema | null }) { const { isMobile } = useSidebar(); const [isDialogOpen, setIsDialogOpen] = useState(false); diff --git a/sigap-website/app/(pages)/(admin)/_components/settings/profile-settings.tsx b/sigap-website/app/(pages)/(admin)/_components/settings/profile-settings.tsx index 5123e3b..3fe9f27 100644 --- a/sigap-website/app/(pages)/(admin)/_components/settings/profile-settings.tsx +++ b/sigap-website/app/(pages)/(admin)/_components/settings/profile-settings.tsx @@ -2,7 +2,7 @@ import type React from "react"; -import type { User } from "@/src/entities/models/users/users.model"; +import type { IUserSchema } from "@/src/entities/models/users/users.model"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -30,7 +30,6 @@ import { useRef, useState } from "react"; import { ScrollArea } from "@/app/_components/ui/scroll-area"; import { updateUser, - uploadAvatar, } from "@/app/(pages)/(admin)/dashboard/user-management/action"; const profileFormSchema = z.object({ @@ -41,7 +40,7 @@ const profileFormSchema = z.object({ type ProfileFormValues = z.infer; interface ProfileSettingsProps { - user: User | null; + user: IUserSchema | null; } export function ProfileSettings({ user }: ProfileSettingsProps) { @@ -70,12 +69,12 @@ export function ProfileSettings({ user }: ProfileSettingsProps) { setIsUploading(true); // Upload avatar to storage - const publicUrl = await uploadAvatar(user.id, user.email, file); + // const publicUrl = await uploadAvatar(user.id, user.email, file); - console.log("publicUrl", publicUrl); + // console.log("publicUrl", publicUrl); // Update the form value - form.setValue("avatar", publicUrl); + // form.setValue("avatar", publicUrl); } catch (error) { console.error("Error uploading avatar:", error); } finally { diff --git a/sigap-website/app/(pages)/(admin)/dashboard/page.tsx b/sigap-website/app/(pages)/(admin)/dashboard/page.tsx index 9802361..a4a5b77 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/page.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/page.tsx @@ -3,15 +3,17 @@ import { createClient } from "@/app/_utils/supabase/server"; import { redirect } from "next/navigation"; export default async function DashboardPage() { - const supabase = await createClient(); + // const supabase = await createClient(); - const { - data: { session }, - } = await supabase.auth.getSession(); + // const { + // data: { user }, + // } = await supabase.auth.getUser(); - if (!session) { - return redirect("/sign-in"); - } + // if (!user) { + // return redirect("/sign-in"); + // } + + // console.log("user", user); return ( <> @@ -20,7 +22,7 @@ export default async function DashboardPage() {
-              {JSON.stringify(session, null, 2)}
+              {/* {JSON.stringify(user, null, 2)} */}
             
diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/add-user-dialog.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/add-user-dialog.tsx index 8b6082a..c508c13 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/add-user-dialog.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/add-user-dialog.tsx @@ -1,155 +1,66 @@ -import type React from "react"; - -import { useState } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/app/_components/ui/dialog"; -import { Button } from "@/app/_components/ui/button"; -import { Input } from "@/app/_components/ui/input"; -import { Checkbox } from "@/app/_components/ui/checkbox"; -import { createUser } from "@/app/(pages)/(admin)/dashboard/user-management/action"; -import { toast } from "sonner"; -import { Mail, Lock, Loader2, X } from "lucide-react"; -import { useMutation } from "@tanstack/react-query"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/app/_components/ui/dialog" +import { Button } from "@/app/_components/ui/button" +import { Mail, Lock, Loader2 } from "lucide-react" +import { useAddUserDialogHandler } from "../handler" +import { ReactHookFormField } from "@/app/_components/react-hook-form-field" interface AddUserDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onUserAdded: () => void; + open: boolean + onOpenChange: (open: boolean) => void + onUserAdded: () => void } - -export function AddUserDialog({ - open, - onOpenChange, - onUserAdded, -}: AddUserDialogProps) { - const [formData, setFormData] = useState({ - email: "", - password: "", - emailConfirm: true, - }); - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - }; - - const { mutate: createUserMutation, isPending } = useMutation({ - mutationKey: ["createUser"], - mutationFn: createUser, - onSuccess: () => { - toast.success("User created successfully."); - onUserAdded(); - onOpenChange(false); - setFormData({ - email: "", - password: "", - emailConfirm: true, - }); - }, - onError: () => { - toast.error("Failed to create user."); - }, - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - try { - await createUserMutation({ - email: formData.email, - password: formData.password, - email_confirm: formData.emailConfirm, - }); - } catch (error) { - toast.error("Failed to create user."); - return; - } - }; +export function AddUserDialog({ open, onOpenChange, onUserAdded }: AddUserDialogProps) { + const { + register, + errors, + isPending, + handleSubmit, + handleOpenChange, + } = useAddUserDialogHandler({ onUserAdded, onOpenChange }); return ( - - + + - - Create a new user - - {/* */} + Create a new user
-
- -
- - -
-
-
- -
- - -
-
+ + +
-
+ {/*
- setFormData((prev) => ({ - ...prev, - emailConfirm: checked as boolean, - })) - } + id="email_confirm" + {...register("email_confirm")} className="border-zinc-700" /> -
-

- A confirmation email will not be sent when creating a user via - this form. +

*/} +

+ A confirmation email will not be sent when creating a user via this form.

-
); } + diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/invite-user.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/invite-user.tsx index 5f1158e..3e21a63 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/invite-user.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/invite-user.tsx @@ -16,6 +16,11 @@ import { Textarea } from "@/app/_components/ui/textarea"; import { useMutation } from "@tanstack/react-query"; import { inviteUser } from "@/app/(pages)/(admin)/dashboard/user-management/action"; import { toast } from "sonner"; +import { useInviteUserHandler } from "../handler"; +import { ReactHookFormField } from "@/app/_components/react-hook-form-field"; +import { Loader2, MailIcon } from "lucide-react"; +import { Separator } from "@/app/_components/ui/separator"; + interface InviteUserDialogProps { open: boolean; @@ -28,52 +33,18 @@ export function InviteUserDialog({ onOpenChange, onUserInvited, }: InviteUserDialogProps) { - const [formData, setFormData] = useState({ - email: "", - metadata: "{}", - }); - const handleInputChange = ( - e: React.ChangeEvent - ) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - }; - - const { mutate: inviteUserMutation, isPending } = useMutation({ - mutationKey: ["inviteUser"], - mutationFn: inviteUser, - onSuccess: () => { - toast.success("Invitation sent"); - onUserInvited(); - onOpenChange(false); - setFormData({ - email: "", - metadata: "{}", - }); - }, - onError: () => { - toast.error("Failed to send invitation"); - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - - let metadata = {}; - try { - metadata = JSON.parse(formData.metadata); - inviteUserMutation({ - email: formData.email, - }); - } catch (error) { - toast.error("Invalid JSON. Please check your metadata format."); - return; - } - }; + const { + register, + handleSubmit, + reset, + errors, + isPending, + handleOpenChange + } = useInviteUserHandler({ onUserInvited, onOpenChange }); return ( - + Invite User @@ -81,30 +52,26 @@ export function InviteUserDialog({ Send an invitation email to a new user. - -
- - -
+ + + - - diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/profile-form.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/profile-form.tsx index 06c1824..c5dcc19 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/profile-form.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/profile-form.tsx @@ -6,7 +6,7 @@ import { useState, useRef } from "react"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import type { User } from "@/src/entities/models/users/users.model"; +import type { IUserSchema } from "@/src/entities/models/users/users.model"; import { Form, @@ -40,7 +40,7 @@ const profileFormSchema = z.object({ type ProfileFormValues = z.infer; interface ProfileFormProps { - user: User | null; + user: IUserSchema | null; onSuccess?: () => void; } diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheet.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheet.tsx index 08ee9a8..feb8f38 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheet.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheet.tsx @@ -35,11 +35,10 @@ import { import { banUser, deleteUser, - sendMagicLink, - sendPasswordRecovery, unbanUser, } from "@/app/(pages)/(admin)/dashboard/user-management/action"; import { format } from "date-fns"; +import { sendMagicLink, sendPasswordRecovery } from "@/app/(pages)/(auth)/action"; interface UserDetailSheetProps { open: boolean; @@ -129,7 +128,8 @@ export function UserDetailSheet({ if (user.banned_until) { return unbanUser(user.id); } else { - return banUser(user.id); + const ban_duration = "7h"; // Example: Ban duration set to 7 days + return banUser(user.id, ban_duration); } }, onMutate: () => { diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/update-user.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/update-user.tsx index 712a164..d21dedd 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/update-user.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/update-user.tsx @@ -5,7 +5,7 @@ import type * as z from "zod" import { Loader2 } from "lucide-react" -import { UpdateUserParamsSchema, type User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" // UI Components import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/app/_components/ui/sheet" @@ -17,20 +17,21 @@ import { FormFieldWrapper } from "@/app/_components/form-wrapper" import { useMutation } from "@tanstack/react-query" import { updateUser } from "../action" import { toast } from "sonner" +import { UpdateUserSchema } from "@/src/entities/models/users/update-user.model" -type UserProfileFormValues = z.infer +type UserProfileFormValues = z.infer interface UserProfileSheetProps { open: boolean onOpenChange: (open: boolean) => void - userData?: User + userData?: IUserSchema onUserUpdated: () => void } export function UserProfileSheet({ open, onOpenChange, userData, onUserUpdated }: UserProfileSheetProps) { // Initialize form with user data const form = useForm({ - resolver: zodResolver(UpdateUserParamsSchema), + resolver: zodResolver(UpdateUserSchema), defaultValues: { email: userData?.email || undefined, encrypted_password: userData?.encrypted_password || undefined, diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-form.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-form.tsx deleted file mode 100644 index 911336e..0000000 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-form.tsx +++ /dev/null @@ -1,147 +0,0 @@ -// "use client" - -// import { zodResolver } from "@hookform/resolvers/zod" -// import { useForm } from "react-hook-form" -// import { z } from "zod" - -// import { Button } from "@/app/_components/ui/button" -// import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/app/_components/ui/form" -// import { Input } from "@/app/_components/ui/input" -// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select" -// import { useState } from "react" -// import { User } from "./column" -// import { updateUser } from "../../user-management/action" -// import { toast } from "@/app/_hooks/use-toast" - -// const userFormSchema = z.object({ -// email: z.string().email({ message: "Please enter a valid email address" }), -// first_name: z.string().nullable(), -// last_name: z.string().nullable(), -// role: z.enum(["user", "admin", "moderator"]), -// }) - -// type UserFormValues = z.infer - -// interface UserFormProps { -// user: User -// } - -// export function UserForm({ user }: UserFormProps) { -// const [isSubmitting, setIsSubmitting] = useState(false) - -// const form = useForm({ -// resolver: zodResolver(userFormSchema), -// defaultValues: { -// email: user.email, -// first_name: user.first_name, -// last_name: user.last_name, -// role: user.role as "user" | "admin" | "moderator", -// }, -// }) - -// async function onSubmit(data: UserFormValues) { -// try { -// setIsSubmitting(true) -// await updateUser(user.id, data) -// toast({ -// title: "User updated", -// description: "The user" + user.email + " has been updated.", -// }) -// } catch (error) { -// toast({ -// title: "Failed to update user", -// description: "An error occurred while updating the user.", -// variant: "destructive", -// }) -// console.error(error) -// } finally { -// setIsSubmitting(false) -// } -// } - -// return ( -//
-// -// ( -// -// Email -// -// -// -// This is the user's email address. -// -// -// )} -// /> -//
-// ( -// -// First Name -// -// -// -// -// -// )} -// /> -// ( -// -// Last Name -// -// -// -// -// -// )} -// /> -//
-// ( -// -// Role -// -// The user's role determines their permissions. -// -// -// )} -// /> -// -// -// -// ) -// } diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx index 5f5fce3..3f86968 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx @@ -16,570 +16,63 @@ import { } from "lucide-react"; import { Button } from "@/app/_components/ui/button"; import { Input } from "@/app/_components/ui/input"; -import { Badge } from "@/app/_components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, - DropdownMenuCheckboxItem, } from "@/app/_components/ui/dropdown-menu"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { fetchUsers } from "@/app/(pages)/(admin)/dashboard/user-management/action"; -import type { User } from "@/src/entities/models/users/users.model"; + import { DataTable } from "./data-table"; import { InviteUserDialog } from "./invite-user"; import { AddUserDialog } from "./add-user-dialog"; import { UserDetailSheet } from "./sheet"; -import { Avatar } from "@radix-ui/react-avatar"; -import Image from "next/image"; -import type { ColumnDef, HeaderContext } from "@tanstack/react-table"; import { UserProfileSheet } from "./update-user"; - -type UserFilterOptions = { - email: string; - phone: string; - lastSignIn: string; - createdAt: string; - status: string[]; -}; - -type UserTableColumn = ColumnDef; +import { filterUsers, useUserManagementHandlers } from "../handler"; +import { createUserColumns } from "./users-table"; +import { useGetUsersQuery } from "../queries"; export default function UserManagement() { - const [searchQuery, setSearchQuery] = useState(""); - const [detailUser, setDetailUser] = useState(null); - const [updateUser, setUpdateUser] = useState(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({ - email: "", - phone: "", - lastSignIn: "", - createdAt: "", - status: [], - }); // Use React Query to fetch users const { data: users = [], - isLoading, + isPending, refetch, - isPlaceholderData, - } = useQuery({ - queryKey: ["users"], - queryFn: fetchUsers, - placeholderData: keepPreviousData, - throwOnError: true, - }); + } = useGetUsersQuery(); - // Handle opening the detail sheet - const handleUserClick = (user: User) => { - setDetailUser(user); - setIsSheetOpen(true); - }; - - // Handle opening the update sheet - const handleUserUpdate = (user: User) => { - 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]); + // User management handler + const { + searchQuery, + setSearchQuery, + detailUser, + updateUser, + isSheetOpen, + setIsSheetOpen, + isUpdateOpen, + setIsUpdateOpen, + isAddUserOpen, + setIsAddUserOpen, + isInviteUserOpen, + setIsInviteUserOpen, + filters, + setFilters, + handleUserClick, + handleUserUpdate, + clearFilters, + getActiveFilterCount, + } = useUserManagementHandlers(refetch) + // Apply filters to users const filteredUsers = useMemo(() => { - 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); + return filterUsers(users, searchQuery, filters) + }, [users, searchQuery, filters]) - if (!matchesSearch) return false; - } + // Get active filter count + const activeFilterCount = getActiveFilterCount() - // 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; - }); - }, [users, searchQuery, filters]); - - const clearFilters = () => { - setFilters({ - email: "", - phone: "", - lastSignIn: "", - createdAt: "", - status: [], - }); - }; - - const activeFilterCount = Object.values(filters).filter( - (value) => - (typeof value === "string" && value !== "") || - (Array.isArray(value) && value.length > 0) - ).length; - - const columns: UserTableColumn[] = [ - { - id: "email", - header: ({ column }: HeaderContext) => ( -
- Email - - - - - -
- - setFilters({ ...filters, email: e.target.value }) - } - className="w-full" - /> -
- - setFilters({ ...filters, email: "" })} - > - Clear filter - -
-
-
- ), - cell: ({ row }) => ( -
- - {row.original.profile?.avatar ? ( - Avatar - ) : ( - row.original.email?.[0]?.toUpperCase() || "?" - )} - -
-
- {row.original.email || "No email"} -
-
- {row.original.id} -
-
-
- ), - }, - { - id: "phone", - header: ({ column }: HeaderContext) => ( -
- Phone - - - - - -
- - setFilters({ ...filters, phone: e.target.value }) - } - className="w-full" - /> -
- - setFilters({ ...filters, phone: "" })} - > - Clear filter - -
-
-
- ), - cell: ({ row }) => row.original.phone || "-", - }, - { - id: "lastSignIn", - header: ({ column }: HeaderContext) => ( -
- Last Sign In - - - - - - - setFilters({ - ...filters, - lastSignIn: filters.lastSignIn === "today" ? "" : "today", - }) - } - > - Today - - - setFilters({ - ...filters, - lastSignIn: filters.lastSignIn === "week" ? "" : "week", - }) - } - > - Last 7 days - - - setFilters({ - ...filters, - lastSignIn: filters.lastSignIn === "month" ? "" : "month", - }) - } - > - Last 30 days - - - setFilters({ - ...filters, - lastSignIn: filters.lastSignIn === "never" ? "" : "never", - }) - } - > - Never - - - setFilters({ ...filters, lastSignIn: "" })} - > - Clear filter - - - -
- ), - cell: ({ row }) => { - return row.original.last_sign_in_at - ? new Date(row.original.last_sign_in_at).toLocaleString() - : "Never"; - }, - }, - { - id: "createdAt", - header: ({ column }: HeaderContext) => ( -
- Created At - - - - - - - setFilters({ - ...filters, - createdAt: filters.createdAt === "today" ? "" : "today", - }) - } - > - Today - - - setFilters({ - ...filters, - createdAt: filters.createdAt === "week" ? "" : "week", - }) - } - > - Last 7 days - - - setFilters({ - ...filters, - createdAt: filters.createdAt === "month" ? "" : "month", - }) - } - > - Last 30 days - - - setFilters({ ...filters, createdAt: "" })} - > - Clear filter - - - -
- ), - cell: ({ row }) => { - return row.original.created_at - ? new Date(row.original.created_at).toLocaleString() - : "N/A"; - }, - }, - { - id: "status", - header: ({ column }: HeaderContext) => ( -
- Status - - - - - - { - const newStatus = [...filters.status]; - if (newStatus.includes("active")) { - newStatus.splice(newStatus.indexOf("active"), 1); - } else { - newStatus.push("active"); - } - setFilters({ ...filters, status: newStatus }); - }} - > - Active - - { - const newStatus = [...filters.status]; - if (newStatus.includes("unconfirmed")) { - newStatus.splice(newStatus.indexOf("unconfirmed"), 1); - } else { - newStatus.push("unconfirmed"); - } - setFilters({ ...filters, status: newStatus }); - }} - > - Unconfirmed - - { - const newStatus = [...filters.status]; - if (newStatus.includes("banned")) { - newStatus.splice(newStatus.indexOf("banned"), 1); - } else { - newStatus.push("banned"); - } - setFilters({ ...filters, status: newStatus }); - }} - > - Banned - - - setFilters({ ...filters, status: [] })} - > - Clear filter - - - -
- ), - cell: ({ row }) => { - if (row.original.banned_until) { - return Banned; - } - if (!row.original.email_confirmed_at) { - return Unconfirmed; - } - return Active; - }, - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( -
e.stopPropagation()}> - {/* Add this wrapper */} - - - - - - handleUserUpdate(row.original)}> - - Update - - { - /* handle delete */ - }} - > - - Delete - - { - /* handle ban */ - }} - > - - {row.original.banned_until != null ? "Unban" : "Ban"} - - - -
- ), - }, - ]; + // Create table columns + const columns = createUserColumns(filters, setFilters, handleUserUpdate) return (
@@ -642,7 +135,7 @@ export default function UserManagement() { handleUserClick(user)} /> {detailUser && ( @@ -653,16 +146,8 @@ export default function UserManagement() { onUserUpdate={() => refetch()} /> )} - refetch()} - /> - refetch()} - /> + refetch()} /> + refetch()} /> {updateUser && ( )}
- ); + ) } diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-stats.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-stats.tsx index b5a20d0..4bec34d 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-stats.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-stats.tsx @@ -1,15 +1,22 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import { Card, CardContent } from "@/app/_components/ui/card"; import { Users, UserCheck, UserX } from "lucide-react"; -import { fetchUsers } from "@/app/(pages)/(admin)/dashboard/user-management/action"; -import { User } from "@/src/entities/models/users/users.model"; -import { useNavigations } from "@/app/_hooks/use-navigations"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; +import { IUserSchema } from "@/src/entities/models/users/users.model"; +import { useGetUsersQuery } from "../queries"; + + +function calculateUserStats(users: IUserSchema[] | undefined) { + if (!users || !Array.isArray(users)) { + return { + totalUsers: 0, + activeUsers: 0, + inactiveUsers: 0, + activePercentage: '0.0', + inactivePercentage: '0.0', + }; + } -function calculateUserStats(users: User[]) { const totalUsers = users.length; const activeUsers = users.filter( (user) => !user.banned_until && user.email_confirmed_at @@ -21,21 +28,18 @@ function calculateUserStats(users: User[]) { activeUsers, inactiveUsers, activePercentage: - totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : "0.0", + totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : '0.0', inactivePercentage: - totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : "0.0", + totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : '0.0', }; } export function UserStats() { - const { data: users = [], isLoading } = useQuery({ - queryKey: ["users"], - queryFn: fetchUsers, - }); + const { data: users, isPending, error } = useGetUsersQuery(); const stats = calculateUserStats(users); - if (isLoading) { + if (isPending) { return ( <> {[...Array(3)].map((_, i) => ( @@ -53,21 +57,32 @@ export function UserStats() { ); } + // Show error state if there's an error + if (error) { + return ( + + +
Error fetching data
+
+
+ ); + } + const cards = [ { - title: "Total Users", + title: 'Total Users', value: stats.totalUsers, - subtitle: "Updated just now", + subtitle: 'Updated just now', icon: Users, }, { - title: "Active Users", + title: 'Active Users', value: stats.activeUsers, subtitle: `${stats.activePercentage}% of total users`, icon: UserCheck, }, { - title: "Inactive Users", + title: 'Inactive Users', value: stats.inactiveUsers, subtitle: `${stats.inactivePercentage}% of total users`, icon: UserX, @@ -92,4 +107,4 @@ export function UserStats() { ))} ); -} +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/users-table.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/users-table.tsx new file mode 100644 index 0000000..9f4f870 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/users-table.tsx @@ -0,0 +1,338 @@ +"use client" + +import type { ColumnDef, HeaderContext } from "@tanstack/react-table" +import type { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model" +import { ListFilter, MoreHorizontal, PenIcon as UserPen, Trash2, ShieldAlert } from "lucide-react" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuCheckboxItem, +} from "@/app/_components/ui/dropdown-menu" +import { Button } from "@/app/_components/ui/button" +import { Input } from "@/app/_components/ui/input" +import { Avatar } from "@/app/_components/ui/avatar" +import Image from "next/image" +import { Badge } from "@/app/_components/ui/badge" + +export type UserTableColumn = ColumnDef + +export const createUserColumns = ( + filters: IUserFilterOptionsSchema, + setFilters: (filters: IUserFilterOptionsSchema) => void, + handleUserUpdate: (user: IUserSchema) => void, +): UserTableColumn[] => { + return [ + { + id: "email", + header: ({ column }: HeaderContext) => ( +
+ Email + + + + + +
+ setFilters({ ...filters, email: e.target.value })} + className="w-full" + /> +
+ + setFilters({ ...filters, email: "" })}>Clear filter +
+
+
+ ), + cell: ({ row }) => ( +
+ + {row.original.profile?.avatar ? ( + Avatar + ) : ( + row.original.email?.[0]?.toUpperCase() || "?" + )} + +
+
{row.original.email || "No email"}
+
{row.original.profile?.username}
+
+
+ ), + }, + { + id: "phone", + header: ({ column }: HeaderContext) => ( +
+ Phone + + + + + +
+ setFilters({ ...filters, phone: e.target.value })} + className="w-full" + /> +
+ + setFilters({ ...filters, phone: "" })}>Clear filter +
+
+
+ ), + cell: ({ row }) => row.original.phone || "-", + }, + { + id: "lastSignIn", + header: ({ column }: HeaderContext) => ( +
+ Last Sign In + + + + + + + setFilters({ + ...filters, + lastSignIn: filters.lastSignIn === "today" ? "" : "today", + }) + } + > + Today + + + setFilters({ + ...filters, + lastSignIn: filters.lastSignIn === "week" ? "" : "week", + }) + } + > + Last 7 days + + + setFilters({ + ...filters, + lastSignIn: filters.lastSignIn === "month" ? "" : "month", + }) + } + > + Last 30 days + + + setFilters({ + ...filters, + lastSignIn: filters.lastSignIn === "never" ? "" : "never", + }) + } + > + Never + + + setFilters({ ...filters, lastSignIn: "" })}> + Clear filter + + + +
+ ), + cell: ({ row }) => { + return row.original.last_sign_in_at ? new Date(row.original.last_sign_in_at).toLocaleString() : "Never" + }, + }, + { + id: "createdAt", + header: ({ column }: HeaderContext) => ( +
+ Created At + + + + + + + setFilters({ + ...filters, + createdAt: filters.createdAt === "today" ? "" : "today", + }) + } + > + Today + + + setFilters({ + ...filters, + createdAt: filters.createdAt === "week" ? "" : "week", + }) + } + > + Last 7 days + + + setFilters({ + ...filters, + createdAt: filters.createdAt === "month" ? "" : "month", + }) + } + > + Last 30 days + + + setFilters({ ...filters, createdAt: "" })}> + Clear filter + + + +
+ ), + cell: ({ row }) => { + return row.original.created_at ? new Date(row.original.created_at).toLocaleString() : "N/A" + }, + }, + { + id: "status", + header: ({ column }: HeaderContext) => ( +
+ Status + + + + + + { + const newStatus = [...filters.status] + if (newStatus.includes("active")) { + newStatus.splice(newStatus.indexOf("active"), 1) + } else { + newStatus.push("active") + } + setFilters({ ...filters, status: newStatus }) + }} + > + Active + + { + const newStatus = [...filters.status] + if (newStatus.includes("unconfirmed")) { + newStatus.splice(newStatus.indexOf("unconfirmed"), 1) + } else { + newStatus.push("unconfirmed") + } + setFilters({ ...filters, status: newStatus }) + }} + > + Unconfirmed + + { + const newStatus = [...filters.status] + if (newStatus.includes("banned")) { + newStatus.splice(newStatus.indexOf("banned"), 1) + } else { + newStatus.push("banned") + } + setFilters({ ...filters, status: newStatus }) + }} + > + Banned + + + setFilters({ ...filters, status: [] })}>Clear filter + + +
+ ), + cell: ({ row }) => { + if (row.original.banned_until) { + return Banned + } + if (!row.original.email_confirmed_at) { + return Unconfirmed + } + return Active + }, + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + + + handleUserUpdate(row.original)}> + + Update + + { + /* handle delete */ + }} + > + + Delete + + { + /* handle ban */ + }} + > + + {row.original.banned_until != null ? "Unban" : "Ban"} + + + +
+ ), + }, + ] +} + diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/action.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/action.ts index 34cbbbb..95172f6 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/action.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/action.ts @@ -1,361 +1,498 @@ "use server"; -import db from "@/prisma/db"; +import db from '@/prisma/db'; +import { createClient } from '@/app/_utils/supabase/server'; +import { createAdminClient } from '@/app/_utils/supabase/admin'; +import { getInjection } from '@/di/container'; +import { InputParseError, NotFoundError } from '@/src/entities/errors/common'; import { - CreateUserParams, - InviteUserParams, - UpdateUserParams, - User, - UserResponse, -} from "@/src/entities/models/users/users.model"; -import { createClient } from "@/app/_utils/supabase/server"; -import { createAdminClient } from "@/app/_utils/supabase/admin"; + AuthenticationError, + UnauthenticatedError, +} from '@/src/entities/errors/auth'; +import { redirect } from 'next/navigation'; +import { IBanDuration, IBanUserSchema, ICredentialsBanUserSchema } from '@/src/entities/models/users/ban-user.model'; +import { ICreateUserSchema } from '@/src/entities/models/users/create-user.model'; +import { IUpdateUserSchema } from '@/src/entities/models/users/update-user.model'; +import { ICredentialsInviteUserSchema } from '@/src/entities/models/users/invite-user.model'; -// Initialize Supabase client with admin key +export async function banUser(id: string, ban_duration: IBanDuration) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'banUser', + { recordResponse: true }, + async () => { + try { + const banUserController = getInjection('IBanUserController'); + await banUserController({ id }, { ban_duration }); -// Fetch all users -export async function fetchUsers(): Promise { - // const { data, error } = await supabase.auth.admin.getUsers(); + return { success: true }; + } catch (err) { + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; - // if (error) { - // console.error("Error fetching users:", error); - // throw new Error(error.message); - // } + throw new InputParseError(err.message); + } - // return data.users.map((user) => ({ - // ...user, - // })) as User[]; + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to ban a user.'); + } - const users = await db.users.findMany({ - include: { - profile: true, - }, - }); + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; - if (!users) { - throw new Error("Users not found"); - } + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } - console.log("fetchedUsers"); - - return users; -} - -// get current user -export async function getCurrentUser(): Promise { - const supabase = await createClient(); - - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error) { - console.error("Error fetching current user:", error); - throw new Error(error.message); - } - - const userDetail = await db.users.findUnique({ - where: { - id: user?.id, - }, - include: { - profile: true, - }, - }); - - if (!userDetail) { - throw new Error("User not found"); - } - - return { - data: { - user: userDetail, - }, - error: null, - }; -} - -// Create a new user -export async function createUser( - params: CreateUserParams -): Promise { - const supabase = createAdminClient(); - - const { data, error } = await supabase.auth.admin.createUser({ - email: params.email, - password: params.password, - phone: params.phone, - email_confirm: params.email_confirm, - }); - - if (error) { - console.error("Error creating user:", error); - throw new Error(error.message); - } - - return { - data: { - user: data.user, - }, - error: null, - }; -} - -export async function uploadAvatar(userId: string, email: string, file: File) { - try { - const supabase = await createClient(); - - const fileExt = file.name.split(".").pop(); - const emailName = email.split("@")[0]; - const fileName = `AVR-${emailName}.${fileExt}`; - - // Change this line - store directly in the user's folder - const filePath = `${userId}/${fileName}`; - - // Upload the avatar to Supabase storage - const { error: uploadError } = await supabase.storage - .from("avatars") - .upload(filePath, file, { - upsert: true, - contentType: file.type, - }); - - if (uploadError) { - console.error("Error uploading avatar:", uploadError); - throw uploadError; + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } } - - // Get the public URL - const { - data: { publicUrl }, - } = supabase.storage.from("avatars").getPublicUrl(filePath); - - // Update user profile with the new avatar URL - await db.users.update({ - where: { - id: userId, - }, - data: { - profile: { - update: { - avatar: publicUrl, - }, - }, - }, - }); - - return publicUrl; - } catch (error) { - console.error("Error uploading avatar:", error); - throw error; - } + ); } +export async function unbanUser(id: string) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'unbanUser', + { recordResponse: true }, + async () => { + try { + const unbanUserController = getInjection('IUnbanUserController'); + await unbanUserController({ id }); -// Update an existing user -export async function updateUser( - userId: string, - params: UpdateUserParams -): Promise { - const supabase = createAdminClient(); + return { success: true }; + } catch (err) { + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; - const { data, error } = await supabase.auth.admin.updateUserById(userId, { - email: params.email, - email_confirm: params.email_confirmed_at, - password: params.encrypted_password ?? undefined, - password_hash: params.encrypted_password ?? undefined, - phone: params.phone, - phone_confirm: params.phone_confirmed_at, - role: params.role, - user_metadata: params.user_metadata, - app_metadata: params.app_metadata, - }); + throw new InputParseError(err.message); + } - if (error) { - console.error("Error updating user:", error); - throw new Error(error.message); - } + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to unban a user.'); + } - const user = await db.users.findUnique({ - where: { - id: userId, - }, - include: { - profile: true, - }, - }); + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; - if (!user) { - throw new Error("User not found"); - } + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } - const updateUser = await db.users.update({ - where: { - id: userId, - }, - data: { - role: params.role || user.role, - invited_at: params.invited_at || user.invited_at, - confirmed_at: params.confirmed_at || user.confirmed_at, - // recovery_sent_at: params.recovery_sent_at || user.recovery_sent_at, - last_sign_in_at: params.last_sign_in_at || user.last_sign_in_at, - is_anonymous: params.is_anonymous || user.is_anonymous, - created_at: params.created_at || user.created_at, - updated_at: params.updated_at || user.updated_at, - profile: { - update: { - avatar: params.profile?.avatar || user.profile?.avatar, - username: params.profile?.username || user.profile?.username, - first_name: params.profile?.first_name || user.profile?.first_name, - last_name: params.profile?.last_name || user.profile?.last_name, - bio: params.profile?.bio || user.profile?.bio, - address: params.profile?.address || user.profile?.address, - birth_date: params.profile?.birth_date || user.profile?.birth_date, - }, - }, - }, - include: { - profile: true, - }, - }); - - return { - data: { - user: { - ...data.user, - role: params.role, - profile: { - user_id: userId, - ...updateUser.profile, - }, - }, - }, - error: null, - }; -} - -// Delete a user -export async function deleteUser(userId: string): Promise { - const supabase = createAdminClient(); - - const { error } = await supabase.auth.admin.deleteUser(userId); - - if (error) { - console.error("Error deleting user:", error); - throw new Error(error.message); - } -} - -// Send password recovery email -export async function sendPasswordRecovery(email: string): Promise { - const supabase = createAdminClient(); - - const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`, - }); - - if (error) { - console.error("Error sending password recovery:", error); - throw new Error(error.message); - } -} - -// Send magic link -export async function sendMagicLink(email: string): Promise { - const supabase = createAdminClient(); - - const { error } = await supabase.auth.signInWithOtp({ - email, - options: { - emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, - }, - }); - - if (error) { - console.error("Error sending magic link:", error); - throw new Error(error.message); - } -} - -// Ban a user -export async function banUser(userId: string): Promise { - const supabase = createAdminClient(); - - // Ban for 100 years (effectively permanent) - const banUntil = new Date(); - banUntil.setFullYear(banUntil.getFullYear() + 100); - - const { data, error } = await supabase.auth.admin.updateUserById(userId, { - ban_duration: "100h", - }); - - if (error) { - console.error("Error banning user:", error); - throw new Error(error.message); - } - - return { - data: { - user: data.user, - }, - error: null, - }; -} - -// Unban a user -export async function unbanUser(userId: string): Promise { - const supabase = createAdminClient(); - - const { data, error } = await supabase.auth.admin.updateUserById(userId, { - ban_duration: "none", - }); - - if (error) { - console.error("Error unbanning user:", error); - throw new Error(error.message); - } - - const user = await db.users.findUnique({ - where: { - id: userId, - }, - select: { - banned_until: true, + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } } - }) - - if (!user) { - throw new Error("User not found"); - } - - // const updateUser = await db.users.update({ - // where: { - // id: userId, - // }, - // data: { - // banned_until: null, - // }, - // }) - - return { - data: { - user: data.user, - }, - error: null, - }; + ); } -// Invite a user -export async function inviteUser(params: InviteUserParams): Promise { - const supabase = createAdminClient(); +export async function getCurrentUser() { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'getCurrentUser', + { recordResponse: true }, + async () => { + try { + const getCurrentUserController = getInjection( + 'IGetCurrentUserController' + ); + return await getCurrentUserController(); + } catch (err) { - const { error } = await supabase.auth.admin.inviteUserByEmail(params.email, { - redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, - }); + if (err instanceof UnauthenticatedError || err instanceof AuthenticationError) { + redirect('/sign-in'); + } - if (error) { - console.error("Error inviting user:", error); - throw new Error(error.message); - } + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); +} + +export async function getUserById(id: string) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'getUserById', + { recordResponse: true }, + async () => { + try { + const getUserByIdController = getInjection('IGetUserByIdController'); + return await getUserByIdController({ id }); + + + } catch (err) { + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; + + throw new InputParseError(err.message); + } + + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to get a user.'); + } + + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; + + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); +} + +export async function getUserByEmail(email: string) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'getUserByEmail', + { recordResponse: true }, + async () => { + try { + const getUserByEmailController = getInjection( + 'IGetUserByEmailController' + ); + return await getUserByEmailController({ email }); + + + } catch (err) { + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; + + throw new InputParseError(err.message); + } + + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to get a user.'); + } + + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; + + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); +} + +export async function getUserByUsername(username: string) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'getUserByUsername', + { recordResponse: true }, + async () => { + try { + const getUserByUsernameController = getInjection( + 'IGetUserByUsernameController' + ); + return await getUserByUsernameController({ username }); + + + } catch (err) { + + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; + + throw new InputParseError(err.message); + } + + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to get a user.'); + } + + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; + + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); +} + +export async function getUsers() { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'getUsers', + { recordResponse: true }, + async () => { + try { + const getUsersController = getInjection('IGetUsersController'); + return await getUsersController(); + } catch (err) { + if ( + err instanceof UnauthenticatedError || + err instanceof AuthenticationError + ) { + redirect('/sign-in'); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); +} + +export async function inviteUser(credentials: ICredentialsInviteUserSchema) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'inviteUser', + { recordResponse: true }, + async () => { + try { + const inviteUserController = getInjection('IInviteUserController'); + await inviteUserController({ email: credentials.email }); + + return { success: true }; + } catch (err) { + + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; + + throw new InputParseError(err.message); + } + + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to invite a user.'); + } + + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; + + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); +} + +export async function createUser(data: ICreateUserSchema) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'createUser', + { recordResponse: true }, + async () => { + try { + const createUserController = getInjection('ICreateUserController'); + await createUserController(data); + + return { success: true }; + + } catch (err) { + + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; + + throw new InputParseError(err.message); + } + + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to create a user.'); + } + + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; + + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); +} + +export async function updateUser(id: string, data: IUpdateUserSchema) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'updateUser', + { recordResponse: true }, + async () => { + try { + const updateUserController = getInjection('IUpdateUserController'); + await updateUserController(id, data); + + return { success: true }; + } catch (err) { + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; + + throw new InputParseError(err.message); + } + + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to update a user.'); + } + + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; + + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); +} + +export async function deleteUser(id: string) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'deleteUser', + { recordResponse: true }, + async () => { + try { + const deleteUserController = getInjection('IDeleteUserController'); + await deleteUserController({ id }); + + return { success: true }; + } catch (err) { + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; + + throw new InputParseError(err.message); + } + + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to delete a user.'); + } + + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; + + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); } diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/handler.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/handler.tsx new file mode 100644 index 0000000..23f1b6f --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/handler.tsx @@ -0,0 +1,308 @@ +import { useEffect, useState } from 'react'; +import { useCreateUserMutation, useInviteUserMutation } 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'; + +export const useAddUserDialogHandler = ({ onUserAdded, onOpenChange }: { + onUserAdded: () => void; + onOpenChange: (open: boolean) => void; +}) => { + const { createUser, isPending } = useCreateUserMutation(); + + const { + register, + handleSubmit, + reset, + formState: { errors: errors }, + setError, + getValues, + clearErrors, + watch, + } = useForm({ + resolver: zodResolver(CreateUserSchema), + defaultValues: { + email: "", + password: "", + email_confirm: true, + } + }); + + const emailConfirm = watch("email_confirm"); + + const onSubmit = handleSubmit(async (data) => { + + await createUser(data, { + onSuccess: () => { + 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 { inviteUser, isPending } = useInviteUserMutation(); + + const { + register, + handleSubmit, + reset, + formState: { errors: errors }, + setError, + getValues, + clearErrors, + watch, + } = useForm({ + resolver: zodResolver(InviteUserSchema), + defaultValues: defaulIInviteUserSchemaValues + }) + + const onSubmit = handleSubmit(async (data) => { + await inviteUser(data, { + onSuccess: () => { + 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 useUserManagementHandlers = (refetch: () => void) => { + const [searchQuery, setSearchQuery] = useState("") + const [detailUser, setDetailUser] = useState(null) + const [updateUser, setUpdateUser] = useState(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({ + 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 + }) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/queries.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/queries.ts new file mode 100644 index 0000000..e92d23e --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/queries.ts @@ -0,0 +1,138 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + banUser, + getCurrentUser, + getUserByEmail, + getUserById, + getUsers, + unbanUser, + inviteUser, + createUser, + updateUser, + deleteUser, + getUserByUsername +} from "./action"; +import { IUserSchema } from "@/src/entities/models/users/users.model"; +import { IBanUserSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model"; +import { IUnbanUserSchema } from "@/src/entities/models/users/unban-user.model"; +import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model"; +import { IUpdateUserSchema } from "@/src/entities/models/users/update-user.model"; +import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model"; + +const useUsersAction = () => { + + // For all users (no parameters needed) + const getUsersQuery = useQuery({ + queryKey: ["users"], + queryFn: async () => await getUsers() + }); + + // Current user query doesn't need parameters + const getCurrentUserQuery = useQuery({ + queryKey: ["user", "current"], + queryFn: async () => await getCurrentUser() + }); + + const getUserByIdQuery = (id: string) => ({ + queryKey: ["user", id], + queryFn: async () => await getUserById(id) + }); + + const getUserByEmailQuery = (email: string) => ({ + queryKey: ["user", "email", email], + queryFn: async () => await getUserByEmail(email) + }); + + const getUserByUsernameQuery = (username: string) => ({ + queryKey: ["user", "username", username], + queryFn: async () => await getUserByUsername(username) + }); + + // Mutations that don't need dynamic parameters + const banUserMutation = useMutation({ + mutationKey: ["banUser"], + mutationFn: async ({ credential, params }: { credential: ICredentialsBanUserSchema; params: IBanUserSchema }) => await banUser(credential.id, params.ban_duration) + }); + + const unbanUserMutation = useMutation({ + mutationKey: ["unbanUser"], + mutationFn: async (params: IUnbanUserSchema) => await unbanUser(params.id) + }); + + // Create functions that return configured hooks + const inviteUserMutation = useMutation({ + mutationKey: ["inviteUser"], + mutationFn: async (credential: ICredentialsInviteUserSchema) => await inviteUser(credential) + }); + + const createUserMutation = useMutation({ + mutationKey: ["createUser"], + mutationFn: async (data: ICreateUserSchema) => await createUser(data) + }); + + const updateUserMutation = useMutation({ + mutationKey: ["updateUser"], + mutationFn: async (params: { id: string; data: IUpdateUserSchema }) => updateUser(params.id, params.data) + }); + + const deleteUserMutation = useMutation({ + mutationKey: ["deleteUser"], + mutationFn: async (id: string) => await deleteUser(id) + }); + + return { + getUsers: getUsersQuery, + getCurrentUser: getCurrentUserQuery, + getUserById: getUserByIdQuery, + getUserByEmailQuery, + getUserByUsernameQuery, + banUser: banUserMutation, + unbanUser: unbanUserMutation, + inviteUser: inviteUserMutation, + createUser: createUserMutation, + updateUser: updateUserMutation, + deleteUser: deleteUserMutation + }; +} + +export const useGetUsersQuery = () => { + const { getUsers } = useUsersAction(); + + return { + data: getUsers.data, + isPending: getUsers.isPending, + error: getUsers.error, + refetch: getUsers.refetch, + }; +} + +export const useGetCurrentUserQuery = () => { + const { getCurrentUser } = useUsersAction(); + + return { + data: getCurrentUser.data, + isPending: getCurrentUser.isPending, + error: getCurrentUser.error, + refetch: getCurrentUser.refetch, + }; +} + +export const useCreateUserMutation = () => { + const { createUser } = useUsersAction(); + + return { + createUser: createUser.mutateAsync, + isPending: createUser.isPending, + errors: createUser.error, + } +} + +export const useInviteUserMutation = () => { + const { inviteUser } = useUsersAction(); + + return { + inviteUser: inviteUser.mutateAsync, + isPending: inviteUser.isPending, + errors: inviteUser.error, + } +} diff --git a/sigap-website/app/(pages)/(auth)/action.ts b/sigap-website/app/(pages)/(auth)/action.ts index cc1b942..936d40b 100644 --- a/sigap-website/app/(pages)/(auth)/action.ts +++ b/sigap-website/app/(pages)/(auth)/action.ts @@ -4,7 +4,7 @@ import { redirect } from "next/navigation" import { getInjection } from "@/di/container" import { revalidatePath } from "next/cache" -import { InputParseError } from "@/src/entities/errors/common" +import { InputParseError, NotFoundError } from "@/src/entities/errors/common" import { AuthenticationError, UnauthenticatedError } from "@/src/entities/errors/auth" import { createClient } from "@/app/_utils/supabase/server" @@ -18,25 +18,22 @@ export async function signIn(formData: FormData) { try { const signInController = getInjection("ISignInController") - await signInController({ email }) + return await signInController({ email }) // if (email) { // redirect(`/verify-otp?email=${encodeURIComponent(email)}`) // } - return { success: true } - } catch (err) { - if ( - err instanceof InputParseError || - err instanceof AuthenticationError - ) { - return { - error: 'Incorrect credential. Please try again.', - }; + if (err instanceof InputParseError) { + return { error: err.message } } - if (err instanceof UnauthenticatedError) { + if (err instanceof AuthenticationError) { + return { error: "Invalid credential. Please try again." } + } + + if (err instanceof UnauthenticatedError || err instanceof NotFoundError) { return { error: 'User not found. Please tell your admin to create an account for you.', }; @@ -136,3 +133,57 @@ export async function verifyOtp(formData: FormData) { } }) } + +export async function sendMagicLink(formData: FormData) { + const instrumentationService = getInjection("IInstrumentationService") + return await instrumentationService.instrumentServerAction("sendMagicLink", { + recordResponse: true + }, async () => { + try { + const email = formData.get("email")?.toString() + + const sendMagicLinkController = getInjection("ISendMagicLinkController") + await sendMagicLinkController({ email }) + + return { success: true } + } catch (err) { + if (err instanceof InputParseError) { + return { error: err.message } + } + + const crashReporterService = getInjection("ICrashReporterService") + crashReporterService.report(err) + + return { + error: "An error occurred during sending magic link. Please try again later.", + } + } + }) +} + +export async function sendPasswordRecovery(formData: FormData) { + const instrumentationService = getInjection("IInstrumentationService") + return await instrumentationService.instrumentServerAction("sendPasswordRecovery", { + recordResponse: true + }, async () => { + try { + const email = formData.get("email")?.toString() + + const sendPasswordRecoveryController = getInjection("ISendPasswordRecoveryController") + await sendPasswordRecoveryController({ email }) + + return { success: true } + } catch (err) { + if (err instanceof InputParseError) { + return { error: err.message } + } + + const crashReporterService = getInjection("ICrashReporterService") + crashReporterService.report(err) + + return { + error: "An error occurred during sending password recovery. Please try again later.", + } + } + }) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(auth)/handler.tsx b/sigap-website/app/(pages)/(auth)/handler.tsx index 1ae3888..5fc335b 100644 --- a/sigap-website/app/(pages)/(auth)/handler.tsx +++ b/sigap-website/app/(pages)/(auth)/handler.tsx @@ -1,19 +1,18 @@ import { AuthenticationError } from "@/src/entities/errors/auth"; import { useState } from "react"; -import { useAuthActions } from "./mutation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { defaultSignInPasswordlessValues, SignInFormData, SignInPasswordless, SignInPasswordlessSchema, SignInSchema } from "@/src/entities/models/auth/sign-in.model"; -import { createFormData } from "@/app/_utils/common"; -import { useFormHandler } from "@/app/_hooks/use-form-handler"; -import { toast } from "sonner"; -import { signIn } from "./action"; -import { useNavigations } from "@/app/_hooks/use-navigations"; -import { VerifyOtpFormData, verifyOtpSchema } from "@/src/entities/models/auth/verify-otp.model"; +import { useAuthActions } from './queries'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod';; +import { toast } from 'sonner'; +import { useNavigations } from '@/app/_hooks/use-navigations'; +import { + IVerifyOtpSchema, + verifyOtpSchema, +} from '@/src/entities/models/auth/verify-otp.model'; /** * Hook untuk menangani proses sign in - * + * * @returns {Object} Object berisi handler dan state untuk form sign in * @example * const { handleSubmit, isPending, error } = useSignInHandler(); @@ -32,23 +31,18 @@ export function useSignInHandler() { setError(undefined); const formData = new FormData(event.currentTarget); - const email = formData.get("email")?.toString() + const email = formData.get('email')?.toString(); - try { - await signIn.mutateAsync(formData, { - onSuccess: () => { - toast("An email has been sent to you. Please check your inbox."); - if (email) router.push(`/verify-otp?email=${encodeURIComponent(email)}`); - }, - onError: (error) => { - setError(error.message); - } - }); - } catch (error) { - if (error instanceof Error) { - setError(error.message); - } + const res = await signIn.mutateAsync(formData); + + if (!res?.error) { + toast('An email has been sent to you. Please check your inbox.'); + if (email) router.push(`/verify-otp?email=${encodeURIComponent(email)}`); + } else { + setError(res.error); } + + }; return { @@ -58,67 +52,66 @@ export function useSignInHandler() { error, isPending: signIn.isPending, errors: !!error || signIn.error, - clearError: () => setError(undefined) + clearError: () => setError(undefined), }; } - - export function useVerifyOtpHandler(email: string) { - const { router } = useNavigations() - const { verifyOtp } = useAuthActions() - const [error, setError] = useState() + const { router } = useNavigations(); + const { verifyOtp } = useAuthActions(); + const [error, setError] = useState(); const { register, handleSubmit: hookFormSubmit, control, formState: { errors }, - setValue - } = useForm({ + setValue, + } = useForm({ resolver: zodResolver(verifyOtpSchema), defaultValues: { email, - token: "" - } - }) + token: '', + }, + }); - const handleOtpChange = (value: string, onChange: (value: string) => void) => { - onChange(value) + const handleOtpChange = ( + value: string, + onChange: (value: string) => void + ) => { + onChange(value); + + if (value.length === 6) { + handleSubmit(); + } // Clear error when user starts typing if (error) { - setError(undefined) + setError(undefined); } - } + }; const handleSubmit = hookFormSubmit(async (data) => { - if (verifyOtp.isPending) return + if (verifyOtp.isPending) return; - setError(undefined) + setError(undefined); // Create FormData object - const formData = new FormData() - formData.append("email", data.email) - formData.append("token", data.token) + const formData = new FormData(); + formData.append('email', data.email); + formData.append('token', data.token); - try { - await verifyOtp.mutateAsync(formData, { - onSuccess: () => { - toast.success("OTP verified successfully") - // Navigate to dashboard on success - router.push("/dashboard") - }, - onError: (error) => { - setError(error.message) - } - }) - } catch (error) { - if (error instanceof Error) { - setError(error.message) - } - } - }) + await verifyOtp.mutateAsync(formData, { + onSuccess: () => { + toast.success('OTP verified successfully'); + // Navigate to dashboard on success + router.push('/dashboard'); + }, + onError: (error) => { + setError(error.message); + }, + }); + }); return { register, @@ -127,50 +120,42 @@ export function useVerifyOtpHandler(email: string) { handleOtpChange, errors: { ...errors, - token: error ? { message: error } : errors.token + token: error ? { message: error } : errors.token, }, isPending: verifyOtp.isPending, - clearError: () => setError(undefined) - } + clearError: () => setError(undefined), + }; } export function useSignOutHandler() { - const { signOut } = useAuthActions() - const { router } = useNavigations() - const [error, setError] = useState() + const { signOut } = useAuthActions(); + const { router } = useNavigations(); + const [error, setError] = useState(); const handleSignOut = async () => { - if (signOut.isPending) return + if (signOut.isPending) return; - setError(undefined) + setError(undefined); - try { - await signOut.mutateAsync(undefined, { - onSuccess: () => { - toast.success("You have been signed out successfully") - router.push("/sign-in") - }, - onError: (error) => { - if (error instanceof AuthenticationError) { - setError(error.message) - toast.error(error.message) - } + await signOut.mutateAsync(undefined, { + onSuccess: () => { + toast.success('You have been signed out successfully'); + router.push('/sign-in'); + }, + onError: (error) => { + if (error instanceof AuthenticationError) { + setError(error.message); + toast.error(error.message); } - }) - } catch (error) { - if (error instanceof Error) { - setError(error.message) - toast.error(error.message) - // toast.error("An error occurred during sign out. Please try again later.") - } - } - } + }, + }); + }; return { handleSignOut, error, isPending: signOut.isPending, errors: !!error || signOut.error, - clearError: () => setError(undefined) - } + clearError: () => setError(undefined), + }; } diff --git a/sigap-website/app/(pages)/(auth)/mutation.ts b/sigap-website/app/(pages)/(auth)/mutation.ts deleted file mode 100644 index cfe4fc9..0000000 --- a/sigap-website/app/(pages)/(auth)/mutation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { queryOptions, useMutation } from '@tanstack/react-query'; -import { signIn, signOut, verifyOtp } from './action'; - -export function useAuthActions() { - // Sign In Mutation - const signInMutation = useMutation({ - mutationKey: ["signIn"], - mutationFn: async (formData: FormData) => { - const response = await signIn(formData); - - // If the server action returns an error, treat it as an error for React Query - if (response?.error) { - throw new Error(response.error); - } - } - }); - - const verifyOtpMutation = useMutation({ - mutationKey: ["verifyOtp"], - mutationFn: async (formData: FormData) => { - const response = await verifyOtp(formData); - - // If the server action returns an error, treat it as an error for React Query - if (response?.error) { - throw new Error(response.error); - } - } - }) - - const signOutMutation = useMutation({ - mutationKey: ["signOut"], - mutationFn: async () => { - const response = await signOut(); - - // If the server action returns an error, treat it as an error for React Query - if (response?.error) { - throw new Error(response.error); - } - } - }) - - return { - signIn: signInMutation, - verifyOtp: verifyOtpMutation, - signOut: signOutMutation - }; -} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(auth)/queries.ts b/sigap-website/app/(pages)/(auth)/queries.ts new file mode 100644 index 0000000..31f17d3 --- /dev/null +++ b/sigap-website/app/(pages)/(auth)/queries.ts @@ -0,0 +1,39 @@ +import { useMutation } from '@tanstack/react-query'; +import { sendMagicLink, sendPasswordRecovery, signIn, signOut, verifyOtp } from './action'; + +export function useAuthActions() { + // Sign In Mutation + const signInMutation = useMutation({ + mutationKey: ["signIn"], + mutationFn: async (formData: FormData) => await signIn(formData) + }); + + // Verify OTP Mutation + const verifyOtpMutation = useMutation({ + mutationKey: ["verifyOtp"], + mutationFn: async (formData: FormData) => await verifyOtp(formData) + }); + + const signOutMutation = useMutation({ + mutationKey: ["signOut"], + mutationFn: async () => await signOut() + }); + + const sendMagicLinkMutation = useMutation({ + mutationKey: ["sendMagicLink"], + mutationFn: async (formData: FormData) => await sendMagicLink(formData) + }); + + const sendPasswordRecoveryMutation = useMutation({ + mutationKey: ["sendPasswordRecovery"], + mutationFn: async (formData: FormData) => await sendPasswordRecovery(formData) + }); + + return { + signIn: signInMutation, + verifyOtp: verifyOtpMutation, + signOut: signOutMutation, + sendMagicLink: sendMagicLinkMutation, + sendPasswordRecovery: sendPasswordRecoveryMutation + }; +} \ No newline at end of file diff --git a/sigap-website/app/_components/header-auth.tsx b/sigap-website/app/_components/header-auth.tsx index 8d1006d..9d511a2 100644 --- a/sigap-website/app/_components/header-auth.tsx +++ b/sigap-website/app/_components/header-auth.tsx @@ -1,71 +1,71 @@ -import { hasEnvVars } from "@/app/_utils/supabase/check-env-vars"; -import Link from "next/link"; -import { Badge } from "./ui/badge"; -import { Button } from "./ui/button"; -import { createClient } from "@/app/_utils/supabase/server"; -import { signOutAction } from "@/app/(pages)/(auth)/_actions/sign-out"; +// import { hasEnvVars } from "@/app/_utils/supabase/check-env-vars"; +// import Link from "next/link"; +// import { Badge } from "./ui/badge"; +// import { Button } from "./ui/button"; +// import { createClient } from "@/app/_utils/supabase/server"; +// import { signOutAction } from "@/app/(pages)/(auth)/_actions/sign-out"; -export default async function AuthButton() { - const supabase = await createClient(); +// export default async function AuthButton() { +// const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); +// const { +// data: { user }, +// } = await supabase.auth.getUser(); - if (!hasEnvVars) { - return ( - <> -
-
- - Please update .env.local file with anon key and url - -
-
- - -
-
- - ); - } - return user ? ( -
- Hey, {user.email}! -
- -
-
- ) : ( -
- - -
- ); -} +// if (!hasEnvVars) { +// return ( +// <> +//
+//
+// +// Please update .env.local file with anon key and url +// +//
+//
+// +// +//
+//
+// +// ); +// } +// return user ? ( +//
+// Hey, {user.email}! +//
+// +//
+//
+// ) : ( +//
+// +// +//
+// ); +// } diff --git a/sigap-website/app/_components/react-hook-form-field.tsx b/sigap-website/app/_components/react-hook-form-field.tsx new file mode 100644 index 0000000..be4d670 --- /dev/null +++ b/sigap-website/app/_components/react-hook-form-field.tsx @@ -0,0 +1,40 @@ +import { Input, InputProps } from "@/app/_components/ui/input" +import { LucideIcon } from "lucide-react" +import { FieldError, UseFormRegisterReturn } from "react-hook-form" + +interface FormFieldProps extends Omit { + id?: string + label: string + icon?: LucideIcon + error?: FieldError + registration: UseFormRegisterReturn +} + +export function ReactHookFormField({ + id, + label, + icon: Icon, + error, + registration, + className, + ...props +}: FormFieldProps) { + return ( +
+ +
+ {Icon && } + + {error &&

{error.message}

} +
+
+ ) +} \ No newline at end of file diff --git a/sigap-website/app/_components/ui/input.tsx b/sigap-website/app/_components/ui/input.tsx index 02ece75..ef202c2 100644 --- a/sigap-website/app/_components/ui/input.tsx +++ b/sigap-website/app/_components/ui/input.tsx @@ -14,7 +14,7 @@ const Input = React.forwardRef( type={type} className={cn( "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", - error && "ring-2 ring-red-500 border-red-500 focus-visible:ring-red-500", + error && "ring-2 ring-red-500 focus-visible:ring-red-500", className )} ref={ref} diff --git a/sigap-website/app/_lib/const/number.ts b/sigap-website/app/_lib/const/number.ts index 37d868b..7ad95c2 100644 --- a/sigap-website/app/_lib/const/number.ts +++ b/sigap-website/app/_lib/const/number.ts @@ -25,4 +25,8 @@ export class CNumbers { static readonly MAX_UPLOAD_SIZE_MB = 50; static readonly MIN_PASSWORD_LENGTH = 8; static readonly MAX_PASSWORD_LENGTH = 128; + + // Phone number + static readonly PHONE_MIN_LENGTH = 10; + static readonly PHONE_MAX_LENGTH = 13; } diff --git a/sigap-website/app/_lib/const/regex.ts b/sigap-website/app/_lib/const/regex.ts new file mode 100644 index 0000000..69ebd83 --- /dev/null +++ b/sigap-website/app/_lib/const/regex.ts @@ -0,0 +1,4 @@ +export class CRegex { + static readonly PHONE_REGEX = /^(\+62|62|0)[8][1-9][0-9]{6,11}$/; + static readonly BAN_DURATION_REGEX = /^(\d+(ns|us|µs|ms|s|m|h)|none)$/; +} \ No newline at end of file diff --git a/sigap-website/app/_lib/const/string.ts b/sigap-website/app/_lib/const/string.ts index d468c3f..66c7066 100644 --- a/sigap-website/app/_lib/const/string.ts +++ b/sigap-website/app/_lib/const/string.ts @@ -84,4 +84,8 @@ export class CTexts { static readonly SENSITIVE_WORDS = [ ] + + // Phone number + static readonly PHONE_PREFIX = ['+62', '62', '0'] + } diff --git a/sigap-website/app/_lib/types/ban-duration.ts b/sigap-website/app/_lib/types/ban-duration.ts new file mode 100644 index 0000000..afcaf65 --- /dev/null +++ b/sigap-website/app/_lib/types/ban-duration.ts @@ -0,0 +1,11 @@ +/** + * Definisikan tipe untuk satuan waktu yang valid + * @description Satuan waktu yang valid: "ns", "us", "µs", "ms", "s", "m", "h" + */ +export type TimeUnit = "ns" | "us" | "µs" | "ms" | "s" | "m" | "h"; + +/** + * Buat tipe untuk durasi yang valid (1ns, 2ms, 10h, dll.) + * @description Format durasi yang valid: `${number}${TimeUnit}` atau "none" + */ +export type ValidBanDuration = `${number}${TimeUnit}` | "none"; \ No newline at end of file diff --git a/sigap-website/app/_styles/globals.css b/sigap-website/app/_styles/globals.css index 373267a..7339b30 100644 --- a/sigap-website/app/_styles/globals.css +++ b/sigap-website/app/_styles/globals.css @@ -4,88 +4,125 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 153 60% 53%; /* Supabase green */ - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 153 60% 53%; /* Matching primary */ + /* Latar belakang terang: putih bersih */ + --background: 0 0% 100%; /* #ffffff */ + --foreground: 0 0% 10%; /* #1a1a1a, teks hitam pekat */ + + /* Kartu: sama dengan latar belakang di mode terang */ + --card: 0 0% 100%; /* #ffffff */ + --card-foreground: 0 0% 10%; /* #1a1a1a */ + + /* Popover: sama dengan latar belakang */ + --popover: 0 0% 100%; /* #ffffff */ + --popover-foreground: 0 0% 10%; /* #1a1a1a */ + + /* Warna utama: hijau Supabase #006239 */ + --primary: 155% 100% 19%; /* #006239 */ + --primary-foreground: 0 0% 100%; /* #ffffff untuk kontras pada hijau */ + + /* Sekunder: abu-abu terang untuk elemen pendukung */ + --secondary: 0 0% 96%; /* #f5f5f5 */ + --secondary-foreground: 0 0% 10%; /* #1a1a1a */ + + /* Muted: abu-abu untuk teks pendukung */ + --muted: 0 0% 85%; /* #d9d9d9 */ + --muted-foreground: 0 0% 40%; /* #666666 */ + + /* Aksen: sama dengan sekunder */ + --accent: 0 0% 96%; /* #f5f5f5 */ + --accent-foreground: 0 0% 10%; /* #1a1a1a */ + + /* Destructive: merah untuk error */ + --destructive: 0 85% 60%; /* #f44336 */ + --destructive-foreground: 0 0% 100%; /* #ffffff */ + + /* Border dan input: abu-abu netral */ + --border: 0 0% 80%; /* #cccccc */ + --input: 0 0% 80%; /* #cccccc */ + + /* Ring: sama dengan primary untuk fokus */ + --ring: 155% 100% 19%; /* #006239 */ + + /* Radius: sudut membulat ringan */ --radius: 0.5rem; - --chart-1: 153 60% 53%; /* Supabase green */ - --chart-2: 183 65% 50%; - --chart-3: 213 70% 47%; - --chart-4: 243 75% 44%; - --chart-5: 273 80% 41%; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; + + /* Chart: gunakan hijau Supabase dan variasi */ + --chart-1: 155% 100% 19%; /* #006239 */ + --chart-2: 160 60% 45%; /* sedikit lebih gelap */ + --chart-3: 165 55% 40%; + --chart-4: 170 50% 35%; + --chart-5: 175 45% 30%; + + /* Sidebar: mirip dengan kartu di mode terang */ + --sidebar-background: 0 0% 98%; /* #fafafa */ + --sidebar-foreground: 0 0% 10%; /* #1a1a1a */ + --sidebar-primary: 155% 100% 19%; /* #006239 */ + --sidebar-primary-foreground: 0 0% 100%; /* #ffffff */ + --sidebar-accent: 0 0% 96%; /* #f5f5f5 */ + --sidebar-accent-foreground: 0 0% 10%; /* #1a1a1a */ + --sidebar-border: 0 0% 85%; /* #d9d9d9 */ + --sidebar-ring: 155% 100% 19%; /* #006239 */ } - + .dark { - --background: 0 0% 9%; /* #171717 */ - --foreground: 210 20% 98%; - --card: 0 0% 9%; /* #171717 */ - --card-foreground: 210 20% 98%; - --popover: 0 0% 9%; /* #171717 */ - --popover-foreground: 210 20% 98%; - --primary: 153 60% 53%; /* Supabase green */ - --primary-foreground: 210 20% 98%; - --secondary: 220 8% 15%; - --secondary-foreground: 210 20% 98%; - --muted: 220 8% 15%; - --muted-foreground: 217 10% 64%; - --accent: 220 8% 15%; - --accent-foreground: 210 20% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 20% 98%; - --border: 220 8% 15%; - --input: 220 8% 15%; - --ring: 153 60% 53%; /* Matching primary */ - --chart-1: 153 60% 53%; /* Supabase green */ - --chart-2: 183 65% 50%; - --chart-3: 213 70% 47%; - --chart-4: 243 75% 44%; - --chart-5: 273 80% 41%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + /* Latar belakang gelap: abu-abu tua mendekati hitam */ + --background: 0 0% 10%; /* #1a1a1a */ + --foreground: 0 0% 85%; /* #d9d9d9, teks abu-abu terang */ + + /* Kartu: sama dengan latar belakang di mode gelap */ + --card: 0 0% 10%; /* #1a1a1a */ + --card-foreground: 0 0% 85%; /* #d9d9d9 */ + + /* Popover: sama dengan latar belakang */ + --popover: 0 0% 10%; /* #1a1a1a */ + --popover-foreground: 0 0% 85%; /* #d9d9d9 */ + + /* Warna utama: hijau Supabase tetap digunakan */ + --primary: 155% 100% 19%; /* #006239 */ + --primary-foreground: 0 0% 100%; /* #ffffff */ + + /* Sekunder: abu-abu gelap untuk elemen pendukung */ + --secondary: 0 0% 15%; /* #262626 */ + --secondary-foreground: 0 0% 85%; /* #d9d9d9 */ + + /* Muted: abu-abu gelap untuk teks pendukung */ + --muted: 0 0% 20%; /* #333333 */ + --muted-foreground: 0 0% 60%; /* #999999 */ + + /* Aksen: sama dengan sekunder */ + --accent: 0 0% 15%; /* #262626 */ + --accent-foreground: 0 0% 85%; /* #d9d9d9 */ + + /* Destructive: merah gelap untuk error */ + --destructive: 0 62% 30%; /* #802626 */ + --destructive-foreground: 0 0% 100%; /* #ffffff */ + + /* Border dan input: abu-abu gelap */ + --border: 0 0% 20%; /* #333333 */ + --input: 0 0% 20%; /* #333333 */ + + /* Ring: sama dengan primary */ + --ring: 155% 100% 19%; /* #006239 */ + + /* Chart: sama seperti mode terang */ + --chart-1: 155% 100% 19%; /* #006239 */ + --chart-2: 160 60% 45%; + --chart-3: 165 55% 40%; + --chart-4: 170 50% 35%; + --chart-5: 175 45% 30%; + + /* Sidebar: abu-abu gelap */ + --sidebar-background: 0 0% 15%; /* #262626 */ + --sidebar-foreground: 0 0% 85%; /* #d9d9d9 */ + --sidebar-primary: 155% 100% 19%; /* #006239 */ + --sidebar-primary-foreground: 0 0% 100%; /* #ffffff */ + --sidebar-accent: 0 0% 20%; /* #333333 */ + --sidebar-accent-foreground: 0 0% 85%; /* #d9d9d9 */ + --sidebar-border: 0 0% 25%; /* #404040 */ + --sidebar-ring: 155% 100% 19%; /* #006239 */ } } -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} - - - @layer base { * { @apply border-border outline-ring/50; @@ -93,4 +130,4 @@ body { @apply bg-background text-foreground; } -} +} \ No newline at end of file diff --git a/sigap-website/app/_utils/common.ts b/sigap-website/app/_utils/common.ts index 32285ca..ca29b33 100644 --- a/sigap-website/app/_utils/common.ts +++ b/sigap-website/app/_utils/common.ts @@ -49,3 +49,25 @@ export function createFormData(): FormData { }); return data; }; + + +/** + * Generates a unique username based on the provided email address. + * + * The username is created by combining the local part of the email (before the '@' symbol) + * with a randomly generated alphanumeric suffix. + * + * @param email - The email address to generate the username from. + * @returns A string representing the generated username. + * + * @example + * ```typescript + * const username = generateUsername("example@gmail.com"); + * console.log(username); // Output: "example.abc123" (random suffix will vary) + * ``` + */ +export function generateUsername(email: string): string { + const [localPart] = email.split("@"); + const randomSuffix = Math.random().toString(36).substring(2, 8); // Generate a random alphanumeric string + return `${localPart}.${randomSuffix}`; +} \ No newline at end of file diff --git a/sigap-website/app/_utils/validation.ts b/sigap-website/app/_utils/validation.ts new file mode 100644 index 0000000..91d0697 --- /dev/null +++ b/sigap-website/app/_utils/validation.ts @@ -0,0 +1,19 @@ +import { CTexts } from "../_lib/const/string"; +import { CRegex } from "../_lib/const/regex"; + +/** + * Validates if a given phone number starts with any of the predefined prefixes. + * + * @param number - The phone number to validate. + * @returns A boolean indicating whether the phone number starts with a valid prefix. + */ +export const phonePrefixValidation = (number: string) => CTexts.PHONE_PREFIX.some(prefix => number.startsWith(prefix)); + + +/** + * Validates if a given phone number matches the predefined regex pattern. + * + * @param number - The phone number to validate. + * @returns A boolean indicating whether the phone number matches the regex pattern. + */ +export const phoneRegexValidation = (number: string) => CRegex.PHONE_REGEX.test(number); \ No newline at end of file diff --git a/sigap-website/di/modules/authentication.module.ts b/sigap-website/di/modules/authentication.module.ts index d9a056c..8176ded 100644 --- a/sigap-website/di/modules/authentication.module.ts +++ b/sigap-website/di/modules/authentication.module.ts @@ -11,6 +11,10 @@ import { signInController } from '@/src/interface-adapters/controllers/auth/sign import { signOutController } from '@/src/interface-adapters/controllers/auth/sign-out.controller'; import { verifyOtpUseCase } from '@/src/application/use-cases/auth/verify-otp.use-case'; import { verifyOtpController } from '@/src/interface-adapters/controllers/auth/verify-otp.controller'; +import { sendMagicLinkUseCase } from '@/src/application/use-cases/auth/send-magic-link.use-case'; +import { sendPasswordRecoveryUseCase } from '@/src/application/use-cases/auth/send-password-recovery.use-case'; +import { sendMagicLinkController } from '@/src/interface-adapters/controllers/auth/send-magic-link.controller'; +import { sendPasswordRecoveryController } from '@/src/interface-adapters/controllers/auth/send-password-recovery.controller'; export function createAuthenticationModule() { const authenticationModule = createModule(); @@ -66,6 +70,22 @@ export function createAuthenticationModule() { DI_SYMBOLS.IAuthenticationService, ]); + authenticationModule + .bind(DI_SYMBOLS.ISendMagicLinkUseCase) + .toHigherOrderFunction(sendMagicLinkUseCase, [ + DI_SYMBOLS.IInstrumentationService, + DI_SYMBOLS.IAuthenticationService, + DI_SYMBOLS.IUsersRepository, + ]); + + authenticationModule + .bind(DI_SYMBOLS.ISendPasswordRecoveryUseCase) + .toHigherOrderFunction(sendPasswordRecoveryUseCase, [ + DI_SYMBOLS.IInstrumentationService, + DI_SYMBOLS.IAuthenticationService, + DI_SYMBOLS.IUsersRepository, + ]); + // Controllers authenticationModule @@ -90,6 +110,20 @@ export function createAuthenticationModule() { DI_SYMBOLS.ISignOutUseCase, ]); + authenticationModule + .bind(DI_SYMBOLS.ISendMagicLinkController) + .toHigherOrderFunction(sendMagicLinkController, [ + DI_SYMBOLS.IInstrumentationService, + DI_SYMBOLS.ISendMagicLinkUseCase, + ]); + + authenticationModule + .bind(DI_SYMBOLS.ISendPasswordRecoveryController) + .toHigherOrderFunction(sendPasswordRecoveryController, [ + DI_SYMBOLS.IInstrumentationService, + DI_SYMBOLS.ISendPasswordRecoveryUseCase, + ]); + return authenticationModule; } \ No newline at end of file diff --git a/sigap-website/di/modules/users.module.ts b/sigap-website/di/modules/users.module.ts index d1e52b0..088fa0f 100644 --- a/sigap-website/di/modules/users.module.ts +++ b/sigap-website/di/modules/users.module.ts @@ -1,9 +1,7 @@ import { createModule } from '@evyweb/ioctopus'; - import { DI_SYMBOLS } from '@/di/types'; import { UsersRepository } from '@/src/infrastructure/repositories/users.repository'; -import { getUsersUseCase } from '@/src/application/use-cases/users/get-users.use-case'; import { getUsersController } from '@/src/interface-adapters/controllers/users/get-users.controller'; import { banUserController } from '@/src/interface-adapters/controllers/users/ban-user.controller'; import { banUserUseCase } from '@/src/application/use-cases/users/ban-user.use-case'; @@ -25,6 +23,7 @@ import { deleteUserUseCase } from '@/src/application/use-cases/users/delete-user import { getUserByUsernameUseCase } from '@/src/application/use-cases/users/get-user-by-username.use-case'; import { getUserByEmailUseCase } from '@/src/application/use-cases/users/get-user-by-email.use-case'; import { updateUserUseCase } from '@/src/application/use-cases/users/update-user.use-case'; +import { getUsersUseCase } from '@/src/application/use-cases/users/get-users.use-case'; export function createUsersModule() { @@ -169,7 +168,7 @@ export function createUsersModule() { ]); usersModule - .bind(DI_SYMBOLS.IGetUserByUserNameController) + .bind(DI_SYMBOLS.IGetUserByUsernameController) .toHigherOrderFunction(getUserByUsernameController, [ DI_SYMBOLS.IInstrumentationService, DI_SYMBOLS.IGetUserByUserNameUseCase @@ -179,14 +178,16 @@ export function createUsersModule() { .bind(DI_SYMBOLS.IInviteUserController) .toHigherOrderFunction(inviteUserController, [ DI_SYMBOLS.IInstrumentationService, - DI_SYMBOLS.IInviteUserUseCase + DI_SYMBOLS.IInviteUserUseCase, + DI_SYMBOLS.IGetCurrentUserUseCase ]); usersModule .bind(DI_SYMBOLS.ICreateUserController) .toHigherOrderFunction(createUserController, [ DI_SYMBOLS.IInstrumentationService, - DI_SYMBOLS.ICreateUserUseCase + DI_SYMBOLS.ICreateUserUseCase, + DI_SYMBOLS.IGetCurrentUserUseCase ]); usersModule diff --git a/sigap-website/di/types.ts b/sigap-website/di/types.ts index 53787ca..937cb8d 100644 --- a/sigap-website/di/types.ts +++ b/sigap-website/di/types.ts @@ -32,6 +32,11 @@ import { IInviteUserController } from '@/src/interface-adapters/controllers/user import { IUpdateUserController } from '@/src/interface-adapters/controllers/users/update-user-controller'; import { ICreateUserController } from '@/src/interface-adapters/controllers/users/create-user.controller'; import { IDeleteUserController } from '@/src/interface-adapters/controllers/users/delete-user.controller'; +import { IGetUsersController } from '@/src/interface-adapters/controllers/users/get-users.controller'; +import { ISendMagicLinkUseCase } from '@/src/application/use-cases/auth/send-magic-link.use-case'; +import { ISendPasswordRecoveryUseCase } from '@/src/application/use-cases/auth/send-password-recovery.use-case'; +import { ISendMagicLinkController } from '@/src/interface-adapters/controllers/auth/send-magic-link.controller'; +import { ISendPasswordRecoveryController } from '@/src/interface-adapters/controllers/auth/send-password-recovery.controller'; export const DI_SYMBOLS = { // Services @@ -48,6 +53,8 @@ export const DI_SYMBOLS = { ISignUpUseCase: Symbol.for('ISignUpUseCase'), IVerifyOtpUseCase: Symbol.for('IVerifyOtpUseCase'), ISignOutUseCase: Symbol.for('ISignOutUseCase'), + ISendMagicLinkUseCase: Symbol.for('ISendMagicLinkUseCase'), + ISendPasswordRecoveryUseCase: Symbol.for('ISendPasswordRecoveryUseCase'), IBanUserUseCase: Symbol.for('IBanUserUseCase'), IUnbanUserUseCase: Symbol.for('IUnbanUserUseCase'), @@ -65,6 +72,8 @@ export const DI_SYMBOLS = { ISignInController: Symbol.for('ISignInController'), ISignOutController: Symbol.for('ISignOutController'), IVerifyOtpController: Symbol.for('IVerifyOtpController'), + ISendMagicLinkController: Symbol.for('ISendMagicLinkController'), + ISendPasswordRecoveryController: Symbol.for('ISendPasswordRecoveryController'), IBanUserController: Symbol.for('IBanUserController'), IUnbanUserController: Symbol.for('IUnbanUserController'), @@ -72,7 +81,7 @@ export const DI_SYMBOLS = { IGetUsersController: Symbol.for('IGetUsersController'), IGetUserByIdController: Symbol.for('IGetUserByIdController'), IGetUserByEmailController: Symbol.for('IGetUserByEmailController'), - IGetUserByUserNameController: Symbol.for('IGetUserByUserNameController'), + IGetUserByUsernameController: Symbol.for('IGetUserByUsernameController'), IInviteUserController: Symbol.for('IInviteUserController'), ICreateUserController: Symbol.for('ICreateUserController'), IUpdateUserController: Symbol.for('IUpdateUserController'), @@ -94,6 +103,8 @@ export interface DI_RETURN_TYPES { ISignUpUseCase: ISignUpUseCase; IVerifyOtpUseCase: IVerifyOtpUseCase; ISignOutUseCase: ISignOutUseCase; + ISendMagicLinkUseCase: ISendMagicLinkUseCase; + ISendPasswordRecoveryUseCase: ISendPasswordRecoveryUseCase; IBanUserUseCase: IBanUserUseCase; IUnbanUserUseCase: IUnbanUserUseCase; @@ -111,14 +122,16 @@ export interface DI_RETURN_TYPES { ISignInController: ISignInController; IVerifyOtpController: IVerifyOtpController; ISignOutController: ISignOutController; + ISendMagicLinkController: ISendMagicLinkController; + ISendPasswordRecoveryController: ISendPasswordRecoveryController; IBanUserController: IBanUserController; IUnbanUserController: IUnbanUserController; IGetCurrentUserController: IGetCurrentUserController; - IGetUsersController: IGetUserByUsernameController; + IGetUsersController: IGetUsersController; IGetUserByIdController: IGetUserByIdController; IGetUserByEmailController: IGetUserByEmailController; - IGetUserByUserNameController: IGetUserByUsernameController; + IGetUserByUsernameController: IGetUserByUsernameController; IInviteUserController: IInviteUserController; ICreateUserController: ICreateUserController; IUpdateUserController: IUpdateUserController; diff --git a/sigap-website/prisma/db.ts b/sigap-website/prisma/db.ts index 1d7d247..e8bacf2 100644 --- a/sigap-website/prisma/db.ts +++ b/sigap-website/prisma/db.ts @@ -2,24 +2,24 @@ import { PrismaClient } from "@prisma/client"; const prismaClientSingleton = () => { return new PrismaClient({ - log: [ - { - emit: 'event', - level: 'query', - }, - { - emit: 'stdout', - level: 'error', - }, - { - emit: 'stdout', - level: 'info', - }, - { - emit: 'stdout', - level: 'warn', - }, - ], + // log: [ + // { + // emit: 'event', + // level: 'query', + // }, + // { + // emit: 'stdout', + // level: 'error', + // }, + // { + // emit: 'stdout', + // level: 'info', + // }, + // { + // emit: 'stdout', + // level: 'warn', + // }, + // ], }) }; @@ -33,8 +33,8 @@ export default db; if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = db; -db.$on('query', (e) => { - console.log('Query: ' + e.query) - console.log('Params: ' + e.params) - console.log('Duration: ' + e.duration + 'ms') -}) \ No newline at end of file +// db.$on('query', (e) => { +// console.log('Query: ' + e.query) +// console.log('Params: ' + e.params) +// console.log('Duration: ' + e.duration + 'ms') +// }) \ No newline at end of file diff --git a/sigap-website/src/application/repositories/authentication.repository.ts b/sigap-website/src/application/repositories/authentication.repository.ts deleted file mode 100644 index 0ae558a..0000000 --- a/sigap-website/src/application/repositories/authentication.repository.ts +++ /dev/null @@ -1,173 +0,0 @@ -"use server"; - -import { createClient as createServerClient } from "@/app/_utils/supabase/server"; -import { SignInFormData } from "@/src/entities/models/auth/sign-in.model"; -import { VerifyOtpFormData } from "@/src/entities/models/auth/verify-otp.model"; -import { AuthenticationError } from "@/src/entities/errors/auth"; -import { DatabaseOperationError } from "@/src/entities/errors/common"; -import { createAdminClient } from "@/app/_utils/supabase/admin"; -import { IInstrumentationServiceImpl } from "@/src/application/services/instrumentation.service.interface"; -import { ICrashReporterServiceImpl } from "@/src/application/services/crash-reporter.service.interface"; - -let supabaseAdmin = createAdminClient(); -let supabaseServer = createServerClient(); - -// Server actions for authentication -export async function signIn({ email }: SignInFormData) { - return await IInstrumentationServiceImpl.instrumentServerAction( - "auth.signIn", - { email }, - async () => { - try { - const supabase = await supabaseServer; - const { data, error } = await supabase.auth.signInWithOtp({ - email, - options: { - shouldCreateUser: false, - }, - }); - - if (error) { - console.error("Error signing in:", error); - throw new AuthenticationError(error.message); - } - - return { - success: true, - message: "Sign in email sent successfully", - data, - redirectTo: `/verify-otp?email=${encodeURIComponent(email)}`, - }; - } catch (err) { - ICrashReporterServiceImpl.report(err); - if (err instanceof AuthenticationError) { - throw err; - } - throw new AuthenticationError("Failed to sign in. Please try again."); - } - } - ); -} - -export async function verifyOtp({ email, token }: VerifyOtpFormData) { - return await IInstrumentationServiceImpl.instrumentServerAction( - "auth.verifyOtp", - { email }, - async () => { - try { - const supabase = await supabaseServer; - const { data, error } = await supabase.auth.verifyOtp({ - email, - token, - type: "email", - }); - - if (error) { - console.error("Error verifying OTP:", error); - throw new AuthenticationError(error.message); - } - - return { - success: true, - message: "Successfully verified!", - data, - redirectTo: "/dashboard", - }; - } catch (err) { - ICrashReporterServiceImpl.report(err); - if (err instanceof AuthenticationError) { - throw err; - } - throw new AuthenticationError("Failed to verify OTP. Please try again."); - } - } - ); -} - -export async function signOut() { - return await IInstrumentationServiceImpl.instrumentServerAction( - "auth.signOut", - {}, - async () => { - try { - const supabase = await supabaseServer; - const { error } = await supabase.auth.signOut(); - - if (error) { - console.error("Error signing out:", error); - throw new AuthenticationError(error.message); - } - - return { - success: true, - message: "Sign out successful", - redirectTo: "/", - }; - } catch (err) { - ICrashReporterServiceImpl.report(err); - throw new AuthenticationError("Failed to sign out. Please try again."); - } - } - ); -} - -export async function sendPasswordRecovery(email: string) { - return await IInstrumentationServiceImpl.instrumentServerAction( - "auth.sendPasswordRecovery", - { email }, - async () => { - try { - const supabase = createAdminClient(); - - const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`, - }); - - if (error) { - console.error("Error sending password recovery:", error); - throw new DatabaseOperationError(error.message); - } - - return { - success: true, - message: "Password recovery email sent successfully", - }; - } catch (err) { - ICrashReporterServiceImpl.report(err); - throw new DatabaseOperationError("Failed to send password recovery email. Please try again."); - } - } - ); -} - -export async function sendMagicLink(email: string) { - return await IInstrumentationServiceImpl.instrumentServerAction( - "auth.sendMagicLink", - { email }, - async () => { - try { - const supabase = createAdminClient(); - - const { error } = await supabase.auth.signInWithOtp({ - email, - options: { - emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, - }, - }); - - if (error) { - console.error("Error sending magic link:", error); - throw new DatabaseOperationError(error.message); - } - - return { - success: true, - message: "Magic link email sent successfully", - }; - } catch (err) { - ICrashReporterServiceImpl.report(err); - throw new DatabaseOperationError("Failed to send magic link email. Please try again."); - } - } - ); -} \ No newline at end of file diff --git a/sigap-website/src/application/repositories/users.repository.interface.ts b/sigap-website/src/application/repositories/users.repository.interface.ts index 68876ac..4415ab2 100644 --- a/sigap-website/src/application/repositories/users.repository.interface.ts +++ b/sigap-website/src/application/repositories/users.repository.interface.ts @@ -1,740 +1,25 @@ import { createAdminClient } from "@/app/_utils/supabase/admin"; import { createClient } from "@/app/_utils/supabase/client"; -import { CreateUser, InviteUser, UpdateUser, User, UserResponse } from "@/src/entities/models/users/users.model"; +import { IUserSchema, UserResponse } from "@/src/entities/models/users/users.model"; import { ITransaction } from "@/src/entities/models/transaction.interface"; +import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model"; +import { ICredentialUpdateUserSchema, IUpdateUserSchema } from "@/src/entities/models/users/update-user.model"; +import { ICredentialsBanUserSchema, IBanUserSchema } from "@/src/entities/models/users/ban-user.model"; +import { ICredentialsUnbanUserSchema } from "@/src/entities/models/users/unban-user.model"; +import { ICredentialsDeleteUserSchema } from "@/src/entities/models/users/delete-user.model"; +import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model"; +import { ICredentialGetUserByEmailSchema, ICredentialGetUserByIdSchema, ICredentialGetUserByUsernameSchema, IGetUserByUsernameSchema } from "@/src/entities/models/users/read-user.model"; export interface IUsersRepository { - getUsers(): Promise; - getCurrentUser(): Promise; - getUserById(id: string): Promise; - getUserByUsername(username: string): Promise; - getUserByEmail(email: string): Promise; - createUser(input: CreateUser, tx?: ITransaction): Promise; - inviteUser(email: string, tx?: ITransaction): Promise; - updateUser(id: string, input: Partial, tx?: ITransaction): Promise; - deleteUser(id: string, tx?: ITransaction): Promise; - banUser(id: string, ban_duration: string, tx?: ITransaction): Promise; - unbanUser(id: string, tx?: ITransaction): Promise; -} - -// export class UsersRepository { -// constructor( -// private readonly instrumentationService: IInstrumentationService, -// private readonly crashReporterService: ICrashReporterService, -// private readonly supabaseAdmin = createAdminClient(), -// private readonly supabaseClient = createClient() -// ) { } - -// async getUsers(): Promise { -// return await this.instrumentationService.startSpan({ -// name: "UsersRepository > getUsers", -// op: 'db.query', -// attributes: { 'db.system': 'postgres' }, -// }, async () => { -// try { - -// const users = await db.users.findMany({ -// include: { -// profile: true, -// }, -// }); - -// if (!users) { -// throw new NotFoundError("Users not found"); -// } - -// return users; - -// } catch (err) { -// this.crashReporterService.report(err); -// throw err; -// } -// }) -// } - -// async getCurrentUser(): Promise { -// return await this.instrumentationService.startSpan({ -// name: "UsersRepository > getCurrentUser", -// op: 'db.query', -// attributes: { 'db.system': 'postgres' }, -// }, async () => { -// try { -// const supabase = await this.supabaseClient; - -// const { -// data: { user }, -// error, -// } = await supabase.auth.getUser(); - -// if (error) { -// console.error("Error fetching current user:", error); -// throw new AuthenticationError(error.message); -// } - -// const userDetail = await db.users.findUnique({ -// where: { -// id: user?.id, -// }, -// include: { -// profile: true, -// }, -// }); - -// if (!userDetail) { -// throw new NotFoundError("User not found"); -// } - -// return { -// data: { -// user: userDetail, -// }, -// error: null, -// }; -// } catch (err) { -// this.crashReporterService.report(err); -// throw err; -// } -// }) -// } - -// async createUser(params: CreateUser): Promise { -// return await this.instrumentationService.startSpan({ -// name: "UsersRepository > createUser", -// op: 'db.query', -// attributes: { 'db.system': 'postgres' }, -// }, async () => { -// try { -// const supabase = this.supabaseAdmin; - -// const { data, error } = await supabase.auth.admin.createUser({ -// email: params.email, -// password: params.password, -// phone: params.phone, -// email_confirm: params.email_confirm, -// }); - -// if (error) { -// console.error("Error creating user:", error); -// throw new DatabaseOperationError(error.message); -// } - -// return { -// data: { -// user: data.user, -// }, -// error: null, -// }; -// } catch (err) { -// this.crashReporterService.report(err); -// throw err; -// } -// }) -// } - -// async inviteUser(params: InviteUser): Promise { -// return await this.instrumentationService.startSpan({ -// name: "UsersRepository > inviteUser", -// op: 'db.query', -// attributes: { 'db.system': 'postgres' }, -// }, async () => { -// try { -// const supabase = this.supabaseAdmin; - -// const { error } = await supabase.auth.admin.inviteUserByEmail(params.email, { -// redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/api/auth/callback`, -// }); - -// if (error) { -// console.error("Error inviting user:", error); -// throw new DatabaseOperationError(error.message); -// } -// } catch (err) { -// this.crashReporterService.report(err); -// throw err; -// } -// }) -// } - -// async uploadAvatar(userId: string, email: string, file: File) { -// return await this.instrumentationService.startSpan({ -// name: "UsersRepository > uploadAvatar", -// op: 'db.query', -// attributes: { 'db.system': 'postgres' }, -// }, async () => { -// try { -// const supabase = await this.supabaseClient; - -// const fileExt = file.name.split(".").pop(); -// const emailName = email.split("@")[0]; -// const fileName = `AVR-${emailName}.${fileExt}`; - -// const filePath = `${userId}/${fileName}`; - -// const { error: uploadError } = await supabase.storage -// .from("avatars") -// .upload(filePath, file, { -// upsert: true, -// contentType: file.type, -// }); - -// if (uploadError) { -// console.error("Error uploading avatar:", uploadError); -// throw new DatabaseOperationError(uploadError.message); -// } - -// const { -// data: { publicUrl }, -// } = supabase.storage.from("avatars").getPublicUrl(filePath); - -// await db.users.update({ -// where: { -// id: userId, -// }, -// data: { -// profile: { -// update: { -// avatar: publicUrl, -// }, -// }, -// }, -// }); - -// return publicUrl; -// } catch (err) { -// this.crashReporterService.report(err); -// throw err; -// } -// }) -// } - -// async updateUser(userId: string, params: UpdateUser): Promise { -// return await this.instrumentationService.startSpan({ -// name: "UsersRepository > updateUser", -// op: 'db.query', -// attributes: { 'db.system': 'postgres' }, -// }, async () => { -// try { -// const supabase = this.supabaseAdmin; - -// const { data, error } = await supabase.auth.admin.updateUserById(userId, { -// email: params.email, -// email_confirm: params.email_confirmed_at, -// password: params.encrypted_password ?? undefined, -// password_hash: params.encrypted_password ?? undefined, -// phone: params.phone, -// phone_confirm: params.phone_confirmed_at, -// role: params.role, -// user_metadata: params.user_metadata, -// app_metadata: params.app_metadata, -// }); - -// if (error) { -// console.error("Error updating user:", error); -// throw new DatabaseOperationError(error.message); -// } - -// const user = await db.users.findUnique({ -// where: { -// id: userId, -// }, -// include: { -// profile: true, -// }, -// }); - -// if (!user) { -// throw new NotFoundError("User not found"); -// } - -// const updateUser = await db.users.update({ -// where: { -// id: userId, -// }, -// data: { -// role: params.role || user.role, -// invited_at: params.invited_at || user.invited_at, -// confirmed_at: params.confirmed_at || user.confirmed_at, -// last_sign_in_at: params.last_sign_in_at || user.last_sign_in_at, -// is_anonymous: params.is_anonymous || user.is_anonymous, -// created_at: params.created_at || user.created_at, -// updated_at: params.updated_at || user.updated_at, -// profile: { -// update: { -// avatar: params.profile?.avatar || user.profile?.avatar, -// username: params.profile?.username || user.profile?.username, -// first_name: params.profile?.first_name || user.profile?.first_name, -// last_name: params.profile?.last_name || user.profile?.last_name, -// bio: params.profile?.bio || user.profile?.bio, -// address: params.profile?.address || user.profile?.address, -// birth_date: params.profile?.birth_date || user.profile?.birth_date, -// }, -// }, -// }, -// include: { -// profile: true, -// }, -// }); - -// return { -// data: { -// user: { -// ...data.user, -// role: params.role, -// profile: { -// user_id: userId, -// ...updateUser.profile, -// }, -// }, -// }, -// error: null, -// }; -// } catch (err) { -// this.crashReporterService.report(err); -// throw err; -// } -// }) -// } - -// async deleteUser(userId: string): Promise { -// return await this.instrumentationService.startSpan({ -// name: "UsersRepository > deleteUser", -// op: 'db.query', -// attributes: { 'db.system': 'postgres' }, -// }, async () => { -// try { -// const supabase = this.supabaseAdmin; - -// const { error } = await supabase.auth.admin.deleteUser(userId); - -// if (error) { -// console.error("Error deleting user:", error); -// throw new DatabaseOperationError(error.message); -// } -// } catch (err) { -// this.crashReporterService.report(err); -// throw err; -// } -// }) -// } - -// async banUser(userId: string): Promise { -// return await this.instrumentationService.startSpan({ -// name: "UsersRepository > banUser", -// op: 'db.query', -// attributes: { 'db.system': 'postgres' }, -// }, async () => { -// try { -// const supabase = this.supabaseAdmin; - -// const { data, error } = await supabase.auth.admin.updateUserById(userId, { -// ban_duration: "100h", -// }); - -// if (error) { -// console.error("Error banning user:", error); -// throw new DatabaseOperationError(error.message); -// } - -// return { -// data: { -// user: data.user, -// }, -// error: null, -// }; -// } catch (err) { -// this.crashReporterService.report(err); -// throw err; -// } -// }) -// } - -// async unbanUser(userId: string): Promise { -// return await this.instrumentationService.startSpan({ -// name: "UsersRepository > unbanUser", -// op: 'db.query', -// attributes: { 'db.system': 'postgres' }, -// }, async () => { -// try { -// const supabase = this.supabaseAdmin; - -// const { data, error } = await supabase.auth.admin.updateUserById(userId, { -// ban_duration: "none", -// }); - -// if (error) { -// console.error("Error unbanning user:", error); -// throw new DatabaseOperationError(error.message); -// } - -// const user = await db.users.findUnique({ -// where: { -// id: userId, -// }, -// select: { -// banned_until: true, -// } -// }); - -// if (!user) { -// throw new NotFoundError("User not found"); -// } - -// return { -// data: { -// user: data.user, -// }, -// error: null, -// }; -// } catch (err) { -// this.crashReporterService.report(err); -// throw err; -// } -// }) -// } -// } - -// export async function fetchUsers(): Promise { -// // const { data, error } = await supabase.auth.admin.getUsers(); - -// // if (error) { -// // console.error("Error fetching users:", error); -// // throw new Error(error.message); -// // } - -// // return data.users.map((user) => ({ -// // ...user, -// // })) as User[]; - -// const users = await db.users.findMany({ -// include: { -// profile: true, -// }, -// }); - -// if (!users) { -// throw new Error("Users not found"); -// } - -// console.log("fetchedUsers"); - -// return users; -// } - -// // get current user -// export async function getCurrentUser(): Promise { -// const supabase = await createClient(); - -// const { -// data: { user }, -// error, -// } = await supabase.auth.getUser(); - -// if (error) { -// console.error("Error fetching current user:", error); -// throw new Error(error.message); -// } - -// const userDetail = await db.users.findUnique({ -// where: { -// id: user?.id, -// }, -// include: { -// profile: true, -// }, -// }); - -// if (!userDetail) { -// throw new Error("User not found"); -// } - -// return { -// data: { -// user: userDetail, -// }, -// error: null, -// }; -// } - -// // Create a new user -// export async function createUser( -// params: CreateUser -// ): Promise { -// const supabase = createAdminClient(); - -// const { data, error } = await supabase.auth.admin.createUser({ -// email: params.email, -// password: params.password, -// phone: params.phone, -// email_confirm: params.email_confirm, -// }); - -// if (error) { -// console.error("Error creating user:", error); -// throw new Error(error.message); -// } - -// return { -// data: { -// user: data.user, -// }, -// error: null, -// }; -// } - -// export async function uploadAvatar(userId: string, email: string, file: File) { -// try { -// const supabase = await createClient(); - -// const fileExt = file.name.split(".").pop(); -// const emailName = email.split("@")[0]; -// const fileName = `AVR-${emailName}.${fileExt}`; - -// // Change this line - store directly in the user's folder -// const filePath = `${userId}/${fileName}`; - -// // Upload the avatar to Supabase storage -// const { error: uploadError } = await supabase.storage -// .from("avatars") -// .upload(filePath, file, { -// upsert: true, -// contentType: file.type, -// }); - -// if (uploadError) { -// console.error("Error uploading avatar:", uploadError); -// throw uploadError; -// } - -// // Get the public URL -// const { -// data: { publicUrl }, -// } = supabase.storage.from("avatars").getPublicUrl(filePath); - -// // Update user profile with the new avatar URL -// await db.users.update({ -// where: { -// id: userId, -// }, -// data: { -// profile: { -// update: { -// avatar: publicUrl, -// }, -// }, -// }, -// }); - -// return publicUrl; -// } catch (error) { -// console.error("Error uploading avatar:", error); -// throw error; -// } -// } - - -// // Update an existing user -// export async function updateUser( -// userId: string, -// params: UpdateUser -// ): Promise { -// const supabase = createAdminClient(); - -// const { data, error } = await supabase.auth.admin.updateUserById(userId, { -// email: params.email, -// email_confirm: params.email_confirmed_at, -// password: params.encrypted_password ?? undefined, -// password_hash: params.encrypted_password ?? undefined, -// phone: params.phone, -// phone_confirm: params.phone_confirmed_at, -// role: params.role, -// user_metadata: params.user_metadata, -// app_metadata: params.app_metadata, -// }); - -// if (error) { -// console.error("Error updating user:", error); -// throw new Error(error.message); -// } - -// const user = await db.users.findUnique({ -// where: { -// id: userId, -// }, -// include: { -// profile: true, -// }, -// }); - -// if (!user) { -// throw new Error("User not found"); -// } - -// const updateUser = await db.users.update({ -// where: { -// id: userId, -// }, -// data: { -// role: params.role || user.role, -// invited_at: params.invited_at || user.invited_at, -// confirmed_at: params.confirmed_at || user.confirmed_at, -// // recovery_sent_at: params.recovery_sent_at || user.recovery_sent_at, -// last_sign_in_at: params.last_sign_in_at || user.last_sign_in_at, -// is_anonymous: params.is_anonymous || user.is_anonymous, -// created_at: params.created_at || user.created_at, -// updated_at: params.updated_at || user.updated_at, -// profile: { -// update: { -// avatar: params.profile?.avatar || user.profile?.avatar, -// username: params.profile?.username || user.profile?.username, -// first_name: params.profile?.first_name || user.profile?.first_name, -// last_name: params.profile?.last_name || user.profile?.last_name, -// bio: params.profile?.bio || user.profile?.bio, -// address: params.profile?.address || user.profile?.address, -// birth_date: params.profile?.birth_date || user.profile?.birth_date, -// }, -// }, -// }, -// include: { -// profile: true, -// }, -// }); - -// return { -// data: { -// user: { -// ...data.user, -// role: params.role, -// profile: { -// user_id: userId, -// ...updateUser.profile, -// }, -// }, -// }, -// error: null, -// }; -// } - -// // Delete a user -// export async function deleteUser(userId: string): Promise { -// const supabase = createAdminClient(); - -// const { error } = await supabase.auth.admin.deleteUser(userId); - -// if (error) { -// console.error("Error deleting user:", error); -// throw new Error(error.message); -// } -// } - -// // Send password recovery email -// export async function sendPasswordRecovery(email: string): Promise { -// const supabase = createAdminClient(); - -// const { error } = await supabase.auth.resetPasswordForEmail(email, { -// redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`, -// }); - -// if (error) { -// console.error("Error sending password recovery:", error); -// throw new Error(error.message); -// } -// } - -// // Send magic link -// export async function sendMagicLink(email: string): Promise { -// const supabase = createAdminClient(); - -// const { error } = await supabase.auth.signInWithOtp({ -// email, -// options: { -// emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, -// }, -// }); - -// if (error) { -// console.error("Error sending magic link:", error); -// throw new Error(error.message); -// } -// } - -// // Ban a user -// export async function banUser(userId: string): Promise { -// const supabase = createAdminClient(); - -// // Ban for 100 years (effectively permanent) -// const banUntil = new Date(); -// banUntil.setFullYear(banUntil.getFullYear() + 100); - -// const { data, error } = await supabase.auth.admin.updateUserById(userId, { -// ban_duration: "100h", -// }); - -// if (error) { -// console.error("Error banning user:", error); -// throw new Error(error.message); -// } - -// return { -// data: { -// user: data.user, -// }, -// error: null, -// }; -// } - -// // Unban a user -// export async function unbanUser(userId: string): Promise { -// const supabase = createAdminClient(); - -// const { data, error } = await supabase.auth.admin.updateUserById(userId, { -// ban_duration: "none", -// }); - -// if (error) { -// console.error("Error unbanning user:", error); -// throw new Error(error.message); -// } - -// const user = await db.users.findUnique({ -// where: { -// id: userId, -// }, -// select: { -// banned_until: true, -// } -// }) - -// if (!user) { -// throw new Error("User not found"); -// } - -// // const updateUser = await db.users.update({ -// // where: { -// // id: userId, -// // }, -// // data: { -// // banned_until: null, -// // }, -// // }) - -// return { -// data: { -// user: data.user, -// }, -// error: null, -// }; -// } - -// // Invite a user -// export async function inviteUser(params: InviteUser): Promise { -// const supabase = createAdminClient(); - -// const { error } = await supabase.auth.admin.inviteUserByEmail(params.email, { -// redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, -// }); - -// if (error) { -// console.error("Error inviting user:", error); -// throw new Error(error.message); -// } -// } + getUsers(): Promise; + getCurrentUser(): Promise; + getUserById(credential: ICredentialGetUserByIdSchema): Promise; + getUserByUsername(credential: ICredentialGetUserByUsernameSchema): Promise; + getUserByEmail(credential: ICredentialGetUserByEmailSchema): Promise; + createUser(input: ICreateUserSchema, tx?: ITransaction): Promise; + inviteUser(credential: ICredentialsInviteUserSchema, tx?: ITransaction): Promise; + updateUser(credential: ICredentialUpdateUserSchema, input: Partial, tx?: ITransaction): Promise; + deleteUser(credential: ICredentialsDeleteUserSchema, tx?: ITransaction): Promise; + banUser(credential: ICredentialsBanUserSchema, input: IBanUserSchema, tx?: ITransaction): Promise; + unbanUser(credential: ICredentialsUnbanUserSchema, tx?: ITransaction): Promise; +} \ No newline at end of file diff --git a/sigap-website/src/application/services/authentication.service.interface.ts b/sigap-website/src/application/services/authentication.service.interface.ts index 01f4d06..7c2d160 100644 --- a/sigap-website/src/application/services/authentication.service.interface.ts +++ b/sigap-website/src/application/services/authentication.service.interface.ts @@ -1,18 +1,20 @@ import { AuthResult } from "@/src/entities/models/auth/auth-result.model" +import { ISendMagicLinkSchema } from "@/src/entities/models/auth/send-magic-link.model" +import { ISendPasswordRecoverySchema } from "@/src/entities/models/auth/send-password-recovery.model" import { Session } from "@/src/entities/models/auth/session.model" -import { SignInFormData, SignInPasswordless, SignInWithPassword } from "@/src/entities/models/auth/sign-in.model" -import { SignUpWithEmail, SignUpWithPhone } from "@/src/entities/models/auth/sign-up.model" -import { VerifyOtpFormData } from "@/src/entities/models/auth/verify-otp.model" -import { User } from "@/src/entities/models/users/users.model" +import { TSignInSchema, ISignInWithPasswordSchema, ISignInPasswordlessSchema } from "@/src/entities/models/auth/sign-in.model" +import { SignUpWithEmailSchema, SignUpWithPhoneSchema, TSignUpSchema, ISignUpWithEmailSchema, ISignUpWithPhoneSchema } from "@/src/entities/models/auth/sign-up.model" +import { IVerifyOtpSchema } from "@/src/entities/models/auth/verify-otp.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" export interface IAuthenticationService { - signInPasswordless(credentials: SignInPasswordless): Promise - signInWithPassword(credentials: SignInWithPassword): Promise - signUpWithEmail(credentials: SignUpWithEmail): Promise - signUpWithPhone(credentials: SignUpWithPhone): Promise + signInPasswordless(credentials: ISignInPasswordlessSchema): Promise + SignInWithPasswordSchema(credentials: ISignInWithPasswordSchema): Promise + SignUpWithEmailSchema(credentials: ISignUpWithEmailSchema): Promise + SignUpWithPhoneSchema(credentials: ISignUpWithPhoneSchema): Promise getSession(): Promise signOut(): Promise - sendMagicLink(email: string): Promise - sendPasswordRecovery(email: string): Promise - verifyOtp(credentials: VerifyOtpFormData): Promise + sendMagicLink(credentials: ISendMagicLinkSchema): Promise + sendPasswordRecovery(credentials: ISendPasswordRecoverySchema): Promise + verifyOtp(credentials: IVerifyOtpSchema): Promise } \ No newline at end of file diff --git a/sigap-website/src/application/use-cases/auth/sign-in.use-case.ts b/sigap-website/src/application/use-cases/auth/sign-in.use-case.ts index a54b392..d6a7d64 100644 --- a/sigap-website/src/application/use-cases/auth/sign-in.use-case.ts +++ b/sigap-website/src/application/use-cases/auth/sign-in.use-case.ts @@ -1,6 +1,6 @@ import type { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface" import { AuthenticationError, UnauthenticatedError } from "@/src/entities/errors/auth"; -import { type SignInFormData, SignInPasswordless, SignInSchema } from "@/src/entities/models/auth/sign-in.model" +import { type TSignInSchema, ISignInPasswordlessSchema, SignInSchema } from "@/src/entities/models/auth/sign-in.model" import { IAuthenticationService } from "@/src/application/services/authentication.service.interface"; import { IUsersRepository } from "../../repositories/users.repository.interface"; @@ -12,11 +12,11 @@ export const signInUseCase = authenticationService: IAuthenticationService, usersRepository: IUsersRepository ) => - async (input: SignInPasswordless): Promise => { + async (input: ISignInPasswordlessSchema): Promise => { return instrumentationService.startSpan({ name: "signIn Use Case", op: "function" }, async () => { - const existingUser = await usersRepository.getUserByEmail(input.email) + const existingUser = await usersRepository.getUserByEmail({ email: input.email }) if (!existingUser) { throw new UnauthenticatedError("User not found. Please tell your admin to create an account for you.") diff --git a/sigap-website/src/application/use-cases/auth/sign-up.use-case.ts b/sigap-website/src/application/use-cases/auth/sign-up.use-case.ts index 7e015c5..6057aa9 100644 --- a/sigap-website/src/application/use-cases/auth/sign-up.use-case.ts +++ b/sigap-website/src/application/use-cases/auth/sign-up.use-case.ts @@ -1,10 +1,10 @@ -import { CreateUser, User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IAuthenticationService } from "../../services/authentication.service.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" import { AuthenticationError } from "@/src/entities/errors/auth" -import { SignUpFormData } from "@/src/entities/models/auth/sign-up.model" -import { AuthResult } from "@/src/entities/models/auth/auth-result.model" +import { ISignUpWithEmailSchema } from "@/src/entities/models/auth/sign-up.model" + export type ISignUpUseCase = ReturnType @@ -13,21 +13,21 @@ export const signUpUseCase = ( instrumentationService: IInstrumentationService, authenticationService: IAuthenticationService, usersRepository: IUsersRepository -) => async (input: SignUpFormData): Promise => { +) => async (input: ISignUpWithEmailSchema): Promise => { return await instrumentationService.startSpan({ name: "signUp Use Case", op: "function" }, async () => { - const existingUser = await usersRepository.getUserByEmail(input.email) + const existingUser = await usersRepository.getUserByEmail({ email: input.email }) if (existingUser) { throw new AuthenticationError("User already exists") } - const newUser = await authenticationService.signUpWithEmail({ + const newUser = await authenticationService.SignUpWithEmailSchema({ email: input.email, password: input.password }) - await authenticationService.signInWithPassword({ + await authenticationService.SignInWithPasswordSchema({ email: input.email, password: input.password }) diff --git a/sigap-website/src/application/use-cases/auth/verify-otp.use-case.ts b/sigap-website/src/application/use-cases/auth/verify-otp.use-case.ts index a99622e..f0bee45 100644 --- a/sigap-website/src/application/use-cases/auth/verify-otp.use-case.ts +++ b/sigap-website/src/application/use-cases/auth/verify-otp.use-case.ts @@ -1,4 +1,4 @@ -import { VerifyOtpFormData } from "@/src/entities/models/auth/verify-otp.model" +import { VerifyOtpSchema } from "@/src/entities/models/auth/verify-otp.model" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IAuthenticationService } from "../../services/authentication.service.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" @@ -12,7 +12,7 @@ export const verifyOtpUseCase = ( instrumentationService: IInstrumentationService, authenticationService: IAuthenticationService, usersRepository: IUsersRepository -) => async (input: VerifyOtpFormData): Promise => { +) => async (input: VerifyOtpSchema): Promise => { return await instrumentationService.startSpan({ name: "verifyOtp Use Case", op: "function" }, async () => { const user = await usersRepository.getUserByEmail(input.email) diff --git a/sigap-website/src/application/use-cases/users/ban-user.use-case.ts b/sigap-website/src/application/use-cases/users/ban-user.use-case.ts index 2800a76..cd51d39 100644 --- a/sigap-website/src/application/use-cases/users/ban-user.use-case.ts +++ b/sigap-website/src/application/use-cases/users/ban-user.use-case.ts @@ -1,23 +1,26 @@ -import { User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" import { NotFoundError } from "@/src/entities/errors/common" +import { IBanUserSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model" export type IBanUserUseCase = ReturnType export const banUserUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository -) => async (id: string, ban_duration: string): Promise => { +) => async (credential: ICredentialsBanUserSchema, input: IBanUserSchema): Promise => { return await instrumentationService.startSpan({ name: "banUser Use Case", op: "function" }, async () => { - const existingUser = await usersRepository.getUserById(id) + const existingUser = await usersRepository.getUserById(credential) if (!existingUser) { throw new NotFoundError("User not found") } - const bannedUser = await usersRepository.banUser(id, ban_duration) + const bannedUser = await usersRepository.banUser(credential, input) + + console.log("Use Case: Ban User") return bannedUser } diff --git a/sigap-website/src/application/use-cases/users/create-user.use-case.ts b/sigap-website/src/application/use-cases/users/create-user.use-case.ts index d6c3a7e..2d7f57a 100644 --- a/sigap-website/src/application/use-cases/users/create-user.use-case.ts +++ b/sigap-website/src/application/use-cases/users/create-user.use-case.ts @@ -2,8 +2,9 @@ import { AuthenticationError } from "@/src/entities/errors/auth" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IAuthenticationService } from "../../services/authentication.service.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" -import { CreateUser, User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" import { InputParseError } from "@/src/entities/errors/common" +import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model" export type ICreateUserUseCase = ReturnType @@ -11,11 +12,11 @@ export type ICreateUserUseCase = ReturnType export const createUserUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository, -) => async (input: CreateUser): Promise => { +) => async (input: ICreateUserSchema): Promise => { return await instrumentationService.startSpan({ name: "createUser Use Case", op: "function" }, async () => { - const existingUser = await usersRepository.getUserByEmail(input.email) + const existingUser = await usersRepository.getUserByEmail({ email: input.email }) if (existingUser) { throw new AuthenticationError("User already exists") diff --git a/sigap-website/src/application/use-cases/users/delete-user.use-case.ts b/sigap-website/src/application/use-cases/users/delete-user.use-case.ts index fd7d2e5..217ac10 100644 --- a/sigap-website/src/application/use-cases/users/delete-user.use-case.ts +++ b/sigap-website/src/application/use-cases/users/delete-user.use-case.ts @@ -1,23 +1,24 @@ import { NotFoundError } from "@/src/entities/errors/common" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" -import { User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" +import { ICredentialsDeleteUserSchema } from "@/src/entities/models/users/delete-user.model" export type IDeleteUserUseCase = ReturnType export const deleteUserUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository -) => async (id: string): Promise => { +) => async (credential: ICredentialsDeleteUserSchema): Promise => { return await instrumentationService.startSpan({ name: "deleteUser Use Case", op: "function" }, async () => { - const user = await usersRepository.getUserById(id) + const user = await usersRepository.getUserById(credential) if (!user) { throw new NotFoundError("User not found") } - const deletedUser = await usersRepository.deleteUser(id) + const deletedUser = await usersRepository.deleteUser(credential) return deletedUser } diff --git a/sigap-website/src/application/use-cases/users/get-current-user.use-case.ts b/sigap-website/src/application/use-cases/users/get-current-user.use-case.ts index 758dae6..58e8e1d 100644 --- a/sigap-website/src/application/use-cases/users/get-current-user.use-case.ts +++ b/sigap-website/src/application/use-cases/users/get-current-user.use-case.ts @@ -1,7 +1,7 @@ import { NotFoundError } from "@/src/entities/errors/common" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" -import { User, UserResponse } from "@/src/entities/models/users/users.model" +import { IUserSchema, UserResponse } from "@/src/entities/models/users/users.model" import { AuthenticationError } from "@/src/entities/errors/auth" @@ -10,7 +10,7 @@ export type IGetCurrentUserUseCase = ReturnType export const getCurrentUserUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository -) => async (): Promise => { +) => async (): Promise => { return await instrumentationService.startSpan({ name: "getCurrentUser Use Case", op: "function" }, async () => { diff --git a/sigap-website/src/application/use-cases/users/get-user-by-email.use-case.ts b/sigap-website/src/application/use-cases/users/get-user-by-email.use-case.ts index a21a651..5029612 100644 --- a/sigap-website/src/application/use-cases/users/get-user-by-email.use-case.ts +++ b/sigap-website/src/application/use-cases/users/get-user-by-email.use-case.ts @@ -1,18 +1,19 @@ -import { User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" import { NotFoundError } from "@/src/entities/errors/common" +import { ICredentialGetUserByEmailSchema } from "@/src/entities/models/users/read-user.model" export type IGetUserByEmailUseCase = ReturnType export const getUserByEmailUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository -) => async (email: string): Promise => { +) => async (credential: ICredentialGetUserByEmailSchema): Promise => { return await instrumentationService.startSpan({ name: "getUserByEmail Use Case", op: "function" }, async () => { - const user = await usersRepository.getUserByEmail(email) + const user = await usersRepository.getUserByEmail(credential) if (!user) { throw new NotFoundError("User not found") diff --git a/sigap-website/src/application/use-cases/users/get-user-by-id.use-case.ts b/sigap-website/src/application/use-cases/users/get-user-by-id.use-case.ts index 54d9816..563a47c 100644 --- a/sigap-website/src/application/use-cases/users/get-user-by-id.use-case.ts +++ b/sigap-website/src/application/use-cases/users/get-user-by-id.use-case.ts @@ -1,8 +1,9 @@ -import { User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" import { AuthenticationError } from "@/src/entities/errors/auth" import { NotFoundError } from "@/src/entities/errors/common" +import { ICredentialGetUserByIdSchema } from "@/src/entities/models/users/read-user.model" export type IGetUserByIdUseCase = ReturnType @@ -10,11 +11,11 @@ export type IGetUserByIdUseCase = ReturnType export const getUserByIdUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository -) => async (id: string): Promise => { +) => async (credential: ICredentialGetUserByIdSchema): Promise => { return await instrumentationService.startSpan({ name: "getUserById Use Case", op: "function" }, async () => { - const user = await usersRepository.getUserById(id) + const user = await usersRepository.getUserById(credential) if (!user) { throw new NotFoundError("User not found") diff --git a/sigap-website/src/application/use-cases/users/get-user-by-username.use-case.ts b/sigap-website/src/application/use-cases/users/get-user-by-username.use-case.ts index 9a39328..747ffe3 100644 --- a/sigap-website/src/application/use-cases/users/get-user-by-username.use-case.ts +++ b/sigap-website/src/application/use-cases/users/get-user-by-username.use-case.ts @@ -1,18 +1,19 @@ import { NotFoundError } from "@/src/entities/errors/common" import { IInstrumentationService } from "../../services/instrumentation.service.interface" import { IUsersRepository } from "../../repositories/users.repository.interface" -import { User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" +import { ICredentialGetUserByUsernameSchema } from "@/src/entities/models/users/read-user.model" export type IGetUserByUsernameUseCase = ReturnType export const getUserByUsernameUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository -) => async (username: string): Promise => { +) => async (credential: ICredentialGetUserByUsernameSchema): Promise => { return await instrumentationService.startSpan({ name: "getUserByUsername Use Case", op: "function" }, async () => { - const user = await usersRepository.getUserByUsername(username) + const user = await usersRepository.getUserByUsername(credential) if (!user) { throw new NotFoundError("User not found") diff --git a/sigap-website/src/application/use-cases/users/get-users.use-case.ts b/sigap-website/src/application/use-cases/users/get-users.use-case.ts index dbfd009..3b92989 100644 --- a/sigap-website/src/application/use-cases/users/get-users.use-case.ts +++ b/sigap-website/src/application/use-cases/users/get-users.use-case.ts @@ -8,7 +8,7 @@ export const getUsersUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository ) => async (): Promise => { - return await instrumentationService.startSpan({ name: "getgetUsers Use Case", op: "function" }, + return instrumentationService.startSpan({ name: "getUsers Use Case", op: "function" }, async () => { const users = await usersRepository.getUsers() diff --git a/sigap-website/src/application/use-cases/users/invite-user.use-case.ts b/sigap-website/src/application/use-cases/users/invite-user.use-case.ts index 146e100..6a3fd87 100644 --- a/sigap-website/src/application/use-cases/users/invite-user.use-case.ts +++ b/sigap-website/src/application/use-cases/users/invite-user.use-case.ts @@ -2,7 +2,8 @@ import { AuthenticationError } from "@/src/entities/errors/auth" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IAuthenticationService } from "../../services/authentication.service.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" -import { User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" +import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model" export type IInviteUserUseCase = ReturnType @@ -10,17 +11,16 @@ export type IInviteUserUseCase = ReturnType export const inviteUserUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository, - authenticationService: IAuthenticationService, -) => async (email: string): Promise => { +) => async (credential: ICredentialsInviteUserSchema): Promise => { return await instrumentationService.startSpan({ name: "inviteUser Use Case", op: "function" }, async () => { - const existingUser = await usersRepository.getUserByEmail(email) + const existingUser = await usersRepository.getUserByEmail(credential) if (existingUser) { throw new AuthenticationError("User already exists") } - const invitedUser = await usersRepository.inviteUser(email) + const invitedUser = await usersRepository.inviteUser(credential) if (!invitedUser) { throw new AuthenticationError("User not invited") diff --git a/sigap-website/src/application/use-cases/users/unban-user.use-case.ts b/sigap-website/src/application/use-cases/users/unban-user.use-case.ts index a094565..107304e 100644 --- a/sigap-website/src/application/use-cases/users/unban-user.use-case.ts +++ b/sigap-website/src/application/use-cases/users/unban-user.use-case.ts @@ -1,23 +1,24 @@ import { NotFoundError } from "@/src/entities/errors/common" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" -import { User } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" +import { ICredentialsUnbanUserSchema } from "@/src/entities/models/users/unban-user.model" export type IUnbanUserUseCase = ReturnType export const unbanUserUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository -) => async (id: string): Promise => { +) => async (credential: ICredentialsUnbanUserSchema): Promise => { return await instrumentationService.startSpan({ name: "unbanUser Use Case", op: "function" }, async () => { - const existingUser = await usersRepository.getUserById(id) + const existingUser = await usersRepository.getUserById(credential) if (!existingUser) { throw new NotFoundError("User not found") } - const unbanUser = await usersRepository.unbanUser(id) + const unbanUser = await usersRepository.unbanUser(credential) return unbanUser } diff --git a/sigap-website/src/application/use-cases/users/update-user.use-case.ts b/sigap-website/src/application/use-cases/users/update-user.use-case.ts index fe3b9a8..2886bd7 100644 --- a/sigap-website/src/application/use-cases/users/update-user.use-case.ts +++ b/sigap-website/src/application/use-cases/users/update-user.use-case.ts @@ -1,24 +1,25 @@ -import { UpdateUser, User, UserResponse } from "@/src/entities/models/users/users.model" +import { IUserSchema } from "@/src/entities/models/users/users.model" import { IUsersRepository } from "../../repositories/users.repository.interface" import { IInstrumentationService } from "../../services/instrumentation.service.interface" import { NotFoundError } from "@/src/entities/errors/common" +import { ICredentialUpdateUserSchema, IUpdateUserSchema } from "@/src/entities/models/users/update-user.model" export type IUpdateUserUseCase = ReturnType export const updateUserUseCase = ( instrumentationService: IInstrumentationService, usersRepository: IUsersRepository -) => async (id: string, input: UpdateUser): Promise => { +) => async (credential: ICredentialUpdateUserSchema, input: IUpdateUserSchema): Promise => { return await instrumentationService.startSpan({ name: "updateUser Use Case", op: "function" }, async () => { - const existingUser = await usersRepository.getUserById(id) + const existingUser = await usersRepository.getUserById(credential) if (!existingUser) { throw new NotFoundError("User not found") } - const updatedUser = await usersRepository.updateUser(id, input) + const updatedUser = await usersRepository.updateUser(credential, input) return updatedUser } diff --git a/sigap-website/src/entities/errors/common.ts b/sigap-website/src/entities/errors/common.ts index 96b9405..9a6accf 100644 --- a/sigap-website/src/entities/errors/common.ts +++ b/sigap-website/src/entities/errors/common.ts @@ -1,18 +1,27 @@ export class DatabaseOperationError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } + constructor(message: string, options?: ErrorOptions) { + super(message, options); } - - export class NotFoundError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } +} + +export class NotFoundError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); } - - export class InputParseError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } +} + +export class InputParseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); } - \ No newline at end of file +} + +export class ServerActionError extends Error { + code: string; + + constructor(message: string, code: string) { + super(message); + this.name = "ServerActionError"; + this.code = code; + } +} diff --git a/sigap-website/src/entities/models/auth/send-magic-link.model.ts b/sigap-website/src/entities/models/auth/send-magic-link.model.ts new file mode 100644 index 0000000..218b780 --- /dev/null +++ b/sigap-website/src/entities/models/auth/send-magic-link.model.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const SendMagicLinkSchema = z.object({ + email: z.string().min(1, { message: "Email is required" }).email({ message: "Please enter a valid email address" }), +}) + +export type ISendMagicLinkSchema = z.infer + +export const defaulISendMagicLinkSchemaValues: ISendMagicLinkSchema = { + email: "", +} \ No newline at end of file diff --git a/sigap-website/src/entities/models/auth/send-password-recovery.model.ts b/sigap-website/src/entities/models/auth/send-password-recovery.model.ts new file mode 100644 index 0000000..26fef74 --- /dev/null +++ b/sigap-website/src/entities/models/auth/send-password-recovery.model.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const SendPasswordRecoverySchema = z.object({ + email: z.string().min(1, { message: "Email is required" }).email({ message: "Please enter a valid email address" }), +}) + +export type ISendPasswordRecoverySchema = z.infer + +export const defaulISendPasswordRecoverySchemaValues: ISendPasswordRecoverySchema = { + email: "", +} \ No newline at end of file diff --git a/sigap-website/src/entities/models/auth/sign-in.model.ts b/sigap-website/src/entities/models/auth/sign-in.model.ts index 369f380..55e9e27 100644 --- a/sigap-website/src/entities/models/auth/sign-in.model.ts +++ b/sigap-website/src/entities/models/auth/sign-in.model.ts @@ -6,40 +6,40 @@ export const SignInSchema = z.object({ .string() .min(1, { message: "Email is required" }) .email({ message: "Please enter a valid email address" }), - password: z.string().min(1, { message: "Password is required" }), + password: z.string().min(1, { message: "Password is required" }).min(8, { message: "Password must be at least 8 characters" }), phone: z.string().optional(), }); // Export the type derived from the schema -export type SignInFormData = z.infer; +export type TSignInSchema = z.infer; -export const SignInWithPassword = SignInSchema.pick({ +export const SignInWithPasswordSchema = SignInSchema.pick({ email: true, password: true, phone: true }) + +export type ISignInWithPasswordSchema = z.infer + // Default values for the form -export const defaultSignInWithPasswordValues: SignInWithPassword = { +export const defaulISignInWithPasswordSchemaValues: ISignInWithPasswordSchema = { email: "", password: "", phone: "" }; -export type SignInWithPassword = z.infer - export const SignInPasswordlessSchema = SignInSchema.pick({ email: true, }) +export type ISignInPasswordlessSchema = z.infer + // Default values for the form -export const defaultSignInPasswordlessValues: SignInPasswordless = { +export const defaulISignInPasswordlessSchemaValues: ISignInPasswordlessSchema = { email: "", } -export type SignInPasswordless = z.infer - - // Define the sign-in response schema using Zod export const SignInResponseSchema = z.object({ success: z.boolean(), diff --git a/sigap-website/src/entities/models/auth/sign-up.model.ts b/sigap-website/src/entities/models/auth/sign-up.model.ts index 63d8e89..c195efb 100644 --- a/sigap-website/src/entities/models/auth/sign-up.model.ts +++ b/sigap-website/src/entities/models/auth/sign-up.model.ts @@ -5,32 +5,42 @@ export const SignUpSchema = z.object({ .string() .min(1, { message: "Email is required" }) .email({ message: "Please enter a valid email address" }), - password: z.string().min(1, { message: "Password is required" }), + password: z.string().min(1, { message: "Password is required" }).min(8, { message: "Password must be at least 8 characters" }), phone: z.string().optional(), }) -export type SignUpFormData = z.infer; +export type TSignUpSchema = z.infer; -export const SignUpWithEmail = SignUpSchema.pick({ +export const SignUpWithEmailSchema = SignUpSchema.pick({ email: true, password: true, }) -export const defaultSignUpWithEmailValues: SignUpWithEmail = { +export type ISignUpWithEmailSchema = z.infer + +export const defaulISignUpWithEmailSchemaValues: ISignUpWithEmailSchema = { email: "", password: "", } -export type SignUpWithEmail = z.infer - -export const SignUpWithPhone = SignUpSchema.pick({ +export const SignUpWithPhoneSchema = SignUpSchema.pick({ phone: true, password: true, }) -export const defaultSignUpWithPhoneValues: SignUpWithPhone = { +export type ISignUpWithPhoneSchema = z.infer + +export const defaulISignUpWithPhoneSchemaValues: ISignUpWithPhoneSchema = { phone: "", password: "", } -export type SignUpWithPhone = z.infer \ No newline at end of file +export const SignUpWithOtpSchema = SignUpSchema.pick({ + email: true, +}) + +export type TSignUpWithOtpSchema = z.infer + +export const defaultSignUpWithOtpSchemaValues: TSignUpWithOtpSchema = { + email: "", +} \ No newline at end of file diff --git a/sigap-website/src/entities/models/auth/verify-otp.model.ts b/sigap-website/src/entities/models/auth/verify-otp.model.ts index 013f0ac..790887e 100644 --- a/sigap-website/src/entities/models/auth/verify-otp.model.ts +++ b/sigap-website/src/entities/models/auth/verify-otp.model.ts @@ -5,9 +5,9 @@ export const verifyOtpSchema = z.object({ token: z.string().length(6, { message: "OTP must be 6 characters long" }), }); -export type VerifyOtpFormData = z.infer; +export type IVerifyOtpSchema = z.infer; -export const defaultVerifyOtpValues: VerifyOtpFormData = { +export const defaultVerifyOtpValues: IVerifyOtpSchema = { email: "", token: "", }; diff --git a/sigap-website/src/entities/models/users/ban-user.model.ts b/sigap-website/src/entities/models/users/ban-user.model.ts new file mode 100644 index 0000000..4a3522d --- /dev/null +++ b/sigap-website/src/entities/models/users/ban-user.model.ts @@ -0,0 +1,30 @@ +// Schema Zod untuk validasi runtime +import { CRegex } from "@/app/_lib/const/regex"; +import { ValidBanDuration } from "@/app/_lib/types/ban-duration"; +import { z } from "zod"; + +export const BanDurationSchema = z.custom( + (value) => typeof value === "string" && CRegex.BAN_DURATION_REGEX.test(value), + { message: "Invalid ban duration format." } +); + +// Tipe untuk digunakan di kode +export type IBanDuration = z.infer; + +export const BanUserCredentialsSchema = z.object({ + id: z.string(), +}) + +export type ICredentialsBanUserSchema = z.infer + +// Schema utama untuk user +export const BanUserSchema = z.object({ + ban_duration: BanDurationSchema, +}); + +export type IBanUserSchema = z.infer; + +// Nilai default +export const defaulIBanUserSchemaValues: IBanUserSchema = { + ban_duration: "none", +}; diff --git a/sigap-website/src/entities/models/users/create-user.model.ts b/sigap-website/src/entities/models/users/create-user.model.ts new file mode 100644 index 0000000..8fec612 --- /dev/null +++ b/sigap-website/src/entities/models/users/create-user.model.ts @@ -0,0 +1,32 @@ +import { CNumbers } from "@/app/_lib/const/number"; +import { CTexts } from "@/app/_lib/const/string"; +import { phonePrefixValidation, phoneRegexValidation } from "@/app/_utils/validation"; +import { z } from "zod"; + +export const CreateUserSchema = z.object({ + email: z.string().min(1, "Email is required").email(), + password: z.string().min(1, { message: "Password is required" }).min(8, { message: "Password must be at least 8 characters" }), + phone: z.string() + .refine(phonePrefixValidation, { + message: `Phone number must start with one of the following: ${CTexts.PHONE_PREFIX.join(', ')}.`, + }) + .refine(phoneRegexValidation, { + message: `Phone number must have a length between ${CNumbers.PHONE_MIN_LENGTH} and ${CNumbers.PHONE_MAX_LENGTH} digits without the country code.`, + }) + .optional(), + email_confirm: z.boolean().optional(), +}); + +export type ICreateUserSchema = z.infer; + +export const defaulICreateUserSchemaValues: ICreateUserSchema = { + email: "", + password: "", + phone: "", + email_confirm: true, +} + +export const CredentialCreateUserSchema = CreateUserSchema.pick({ + email: true, +}) + diff --git a/sigap-website/src/entities/models/users/delete-user.model.ts b/sigap-website/src/entities/models/users/delete-user.model.ts new file mode 100644 index 0000000..ec4568f --- /dev/null +++ b/sigap-website/src/entities/models/users/delete-user.model.ts @@ -0,0 +1,15 @@ +import { z } from "zod" + +export const DeleteUserSchema = z.object({ + id: z.string(), +}) + +export type IDeleteUserSchema = z.infer + +export const defaulIDeleteUserSchemaValues: IDeleteUserSchema = { + id: "", +} + +export const DeleteUserCredentialsSchema = DeleteUserSchema.pick({ id: true }) + +export type ICredentialsDeleteUserSchema = z.infer \ No newline at end of file diff --git a/sigap-website/src/entities/models/users/invite-user.model.ts b/sigap-website/src/entities/models/users/invite-user.model.ts new file mode 100644 index 0000000..05a7384 --- /dev/null +++ b/sigap-website/src/entities/models/users/invite-user.model.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const InviteUserSchema = z.object({ + email: z.string().min(1, "Email is required").email(), +}); + +export type IInviteUserSchema = z.infer; + +export const defaulIInviteUserSchemaValues: IInviteUserSchema = { + email: "", +} + +export const InviteUserCredentialsSchema = InviteUserSchema.pick({ email: true }) + +export type ICredentialsInviteUserSchema = z.infer \ No newline at end of file diff --git a/sigap-website/src/entities/models/users/read-user.model.ts b/sigap-website/src/entities/models/users/read-user.model.ts new file mode 100644 index 0000000..9ef6287 --- /dev/null +++ b/sigap-website/src/entities/models/users/read-user.model.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +/** + * Schema untuk mendapatkan user berdasarkan ID + * @typedef {Object} IGetUserByIdSchema + * @property {string} id - ID pengguna yang akan dicari + */ +export const GetUserByIdSchema = z.object({ + id: z.string(), +}); + +export type IGetUserByIdSchema = z.infer; + +export const defaulIGetUserByIdSchemaValues: IGetUserByIdSchema = { + id: "", +}; + +/** + * Schema credential untuk mendapatkan user berdasarkan ID + * Mengambil hanya properti 'id' dari GetUserByIdSchema + */ +export const ICredentialGetUserByIdSchema = GetUserByIdSchema.pick({ id: true }); + +export type ICredentialGetUserByIdSchema = z.infer; + +export const GetUserByEmailSchema = z.object({ + email: z.string().email(), +}); + +/** + * Tipe inferensi dari GetUserByEmailSchema + * @type {z.infer} + */ +export type IGetUserByEmailSchema = z.infer; + +export const defaulIGetUserByEmailSchemaValues: IGetUserByEmailSchema = { + email: "", +}; + +export const ICredentialGetUserByEmailSchema = GetUserByEmailSchema.pick({ email: true }); + +export type ICredentialGetUserByEmailSchema = z.infer; + +/** + * Schema untuk mendapatkan user berdasarkan username + * @typedef {Object} IGetUserByUsernameSchema + * @property {string} username - Nama pengguna yang akan dicari + */ +export const GetUserByUsernameSchema = z.object({ + username: z.string(), +}); + +export type IGetUserByUsernameSchema = z.infer; + +export const defaulIGetUserByUsernameSchemaValues: IGetUserByUsernameSchema = { + username: "", +}; + +export const ICredentialGetUserByUsernameSchema = GetUserByUsernameSchema.pick({ username: true }); + +export type ICredentialGetUserByUsernameSchema = z.infer; \ No newline at end of file diff --git a/sigap-website/src/entities/models/users/unban-user.model.ts b/sigap-website/src/entities/models/users/unban-user.model.ts new file mode 100644 index 0000000..389e61e --- /dev/null +++ b/sigap-website/src/entities/models/users/unban-user.model.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const UnbanUserSchema = z.object({ + id: z.string(), +}) + +export type IUnbanUserSchema = z.infer; + +export const defaulIUnbanUserSchemaValues: IUnbanUserSchema = { + id: "", +} + +export const UnbanUserCredentialsSchema = UnbanUserSchema.pick({ id: true }) + +export type ICredentialsUnbanUserSchema = z.infer \ No newline at end of file diff --git a/sigap-website/src/entities/models/users/update-user.model.ts b/sigap-website/src/entities/models/users/update-user.model.ts new file mode 100644 index 0000000..7628257 --- /dev/null +++ b/sigap-website/src/entities/models/users/update-user.model.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +export const UpdateUserSchema = z.object({ + email: z.string().email().optional(), + email_confirmed_at: z.boolean().optional(), + encrypted_password: z.string().optional(), + role: z.enum(["user", "staff", "admin"]).optional(), + phone: z.string().optional(), + phone_confirmed_at: z.boolean().optional(), + invited_at: z.union([z.string(), z.date()]).optional(), + confirmed_at: z.union([z.string(), z.date()]).optional(), + // recovery_sent_at: z.union([z.string(), z.date()]).optional(), + last_sign_in_at: z.union([z.string(), z.date()]).optional(), + created_at: z.union([z.string(), z.date()]).optional(), + updated_at: z.union([z.string(), z.date()]).optional(), + is_anonymous: z.boolean().optional(), + user_metadata: z.record(z.any()).optional(), + app_metadata: z.record(z.any()).optional(), + profile: z + .object({ + avatar: z.string().optional(), + username: z.string().optional(), + first_name: z.string().optional(), + last_name: z.string().optional(), + bio: z.string().optional(), + address: z.any().optional(), + birth_date: z.date().optional(), + }) +}); + +export type IUpdateUserSchema = z.infer; + +export const defaulIUpdateUserSchemaValues: IUpdateUserSchema = { + email: "", + email_confirmed_at: false, + encrypted_password: "", + role: "user", + phone: "", + phone_confirmed_at: false, + invited_at: "", + confirmed_at: "", + last_sign_in_at: "", + created_at: "", + updated_at: "", + is_anonymous: false, + user_metadata: {}, + app_metadata: {}, + profile: { + avatar: "", + username: "", + first_name: "", + last_name: "", + bio: "", + address: "", + birth_date: new Date(), + } +} + +export const CredentialUpdateUserSchema = z.object({ + id: z.string(), +}) + +export type ICredentialUpdateUserSchema = z.infer \ No newline at end of file diff --git a/sigap-website/src/entities/models/users/users.model.ts b/sigap-website/src/entities/models/users/users.model.ts index 5a65076..c6cc234 100644 --- a/sigap-website/src/entities/models/users/users.model.ts +++ b/sigap-website/src/entities/models/users/users.model.ts @@ -66,7 +66,7 @@ export const UserSchema = z.object({ .optional(), }); -export type User = z.infer; +export type IUserSchema = z.infer; export const ProfileSchema = z.object({ id: z.string(), @@ -80,59 +80,30 @@ export const ProfileSchema = z.object({ birth_date: z.string().optional(), }); -export type Profile = z.infer; +export type IProfileSchema = z.infer; -export const CreateUserSchema = z.object({ - email: z.string().email(), - password: z.string().min(8), - phone: z.string().optional(), - email_confirm: z.boolean().optional(), -}); +// export type UserFilterOptions = { +// email: string +// phone: string +// lastSignIn: string +// createdAt: string +// status: string[] +// } -export type CreateUser = z.infer; +export const UserFilterOptionsSchema = z.object({ + email: z.string(), + phone: z.string(), + lastSignIn: z.string(), + createdAt: z.string(), + status: z.array(z.string()), +}) -export const UpdateUserSchema = z.object({ - email: z.string().email().optional(), - email_confirmed_at: z.boolean().optional(), - encrypted_password: z.string().optional(), - role: z.enum(["user", "staff", "admin"]).optional(), - phone: z.string().optional(), - phone_confirmed_at: z.boolean().optional(), - invited_at: z.union([z.string(), z.date()]).optional(), - confirmed_at: z.union([z.string(), z.date()]).optional(), - // recovery_sent_at: z.union([z.string(), z.date()]).optional(), - last_sign_in_at: z.union([z.string(), z.date()]).optional(), - created_at: z.union([z.string(), z.date()]).optional(), - updated_at: z.union([z.string(), z.date()]).optional(), - is_anonymous: z.boolean().optional(), - user_metadata: z.record(z.any()).optional(), - app_metadata: z.record(z.any()).optional(), - profile: z - .object({ - id: z.string().optional(), - user_id: z.string(), - avatar: z.string().optional(), - username: z.string().optional(), - first_name: z.string().optional(), - last_name: z.string().optional(), - bio: z.string().optional(), - address: z.any().optional(), - birth_date: z.date().optional(), - }) -}); - -export type UpdateUser = z.infer; - -export const InviteUserSchema = z.object({ - email: z.string().email(), -}); - -export type InviteUser = z.infer; +export type IUserFilterOptionsSchema = z.infer; export type UserResponse = | { data: { - user: User; + user: IUserSchema; }; error: null; } diff --git a/sigap-website/src/infrastructure/repositories/users.repository.ts b/sigap-website/src/infrastructure/repositories/users.repository.ts index 68ad50a..f539e16 100644 --- a/sigap-website/src/infrastructure/repositories/users.repository.ts +++ b/sigap-website/src/infrastructure/repositories/users.repository.ts @@ -3,11 +3,18 @@ import { ICrashReporterService } from "@/src/application/services/crash-reporter import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface"; import { createAdminClient } from "@/app/_utils/supabase/admin"; import { createClient as createServerClient } from "@/app/_utils/supabase/server"; -import { CreateUser, UpdateUser, User, UserResponse } from "@/src/entities/models/users/users.model"; +import { IUserSchema } from "@/src/entities/models/users/users.model"; import { ITransaction } from "@/src/entities/models/transaction.interface"; import db from "@/prisma/db"; import { DatabaseOperationError, NotFoundError } from "@/src/entities/errors/common"; import { AuthenticationError } from "@/src/entities/errors/auth"; +import { ICredentialGetUserByEmailSchema, ICredentialGetUserByUsernameSchema, IGetUserByIdSchema } from "@/src/entities/models/users/read-user.model"; +import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model"; +import { ICredentialUpdateUserSchema, IUpdateUserSchema } from "@/src/entities/models/users/update-user.model"; +import { ICredentialsDeleteUserSchema } from "@/src/entities/models/users/delete-user.model"; +import { IBanUserSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model"; +import { ICredentialsUnbanUserSchema } from "@/src/entities/models/users/unban-user.model"; +import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model"; export class UsersRepository implements IUsersRepository { constructor( @@ -17,7 +24,7 @@ export class UsersRepository implements IUsersRepository { private readonly supabaseServer = createServerClient() ) { } - async getUsers(): Promise { + async getUsers(): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > getUsers", }, async () => { @@ -40,7 +47,7 @@ export class UsersRepository implements IUsersRepository { ) if (!users) { - throw new NotFoundError("Users not found"); + return []; } return users; @@ -51,14 +58,14 @@ export class UsersRepository implements IUsersRepository { }) } - async getUserById(id: string): Promise { + async getUserById(credential: IGetUserByIdSchema): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > getUserById", }, async () => { try { const query = db.users.findUnique({ where: { - id, + id: credential.id, }, include: { profile: true, @@ -66,7 +73,7 @@ export class UsersRepository implements IUsersRepository { }) const user = await this.instrumentationService.startSpan({ - name: `UsersRepository > getUserById > Prisma: db.users.findUnique(${id})`, + name: `UsersRepository > getUserById > Prisma: db.users.findUnique(${credential.id})`, op: "db:query", attributes: { "system": "prisma" }, }, @@ -81,7 +88,7 @@ export class UsersRepository implements IUsersRepository { return { ...user, - id, + id: credential.id, }; } catch (err) { this.crashReporterService.report(err); @@ -90,7 +97,7 @@ export class UsersRepository implements IUsersRepository { }) } - async getUserByUsername(username: string): Promise { + async getUserByUsername(credential: ICredentialGetUserByUsernameSchema): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > getUserByUsername", }, async () => { @@ -98,7 +105,7 @@ export class UsersRepository implements IUsersRepository { const query = db.users.findFirst({ where: { profile: { - username, + username: credential.username, }, }, include: { @@ -107,7 +114,7 @@ export class UsersRepository implements IUsersRepository { }) const user = await this.instrumentationService.startSpan({ - name: `UsersRepository > getUserByUsername > Prisma: db.users.findFirst(${username})`, + name: `UsersRepository > getUserByUsername > Prisma: db.users.findFirst(${credential.username})`, op: "db:query", attributes: { "system": "prisma" }, }, @@ -130,7 +137,7 @@ export class UsersRepository implements IUsersRepository { }) } - async getUserByEmail(email: string): Promise { + async getUserByEmail(credential: ICredentialGetUserByEmailSchema): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > getUserByEmail", }, async () => { @@ -138,7 +145,7 @@ export class UsersRepository implements IUsersRepository { const query = db.users.findUnique({ where: { - email, + email: credential.email, }, include: { profile: true, @@ -146,7 +153,7 @@ export class UsersRepository implements IUsersRepository { }) const user = await this.instrumentationService.startSpan({ - name: `UsersRepository > getUserByEmail > Prisma: db.users.findUnique(${email})`, + name: `UsersRepository > getUserByEmail > Prisma: db.users.findUnique(${credential.email})`, op: "db:query", attributes: { "system": "prisma" }, }, @@ -156,7 +163,7 @@ export class UsersRepository implements IUsersRepository { ) if (!user) { - throw new NotFoundError("User not found"); + return undefined; } return user; @@ -167,7 +174,7 @@ export class UsersRepository implements IUsersRepository { }) } - async getCurrentUser(): Promise { + async getCurrentUser(): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > getCurrentUser", }, async () => { @@ -176,7 +183,7 @@ export class UsersRepository implements IUsersRepository { const query = supabase.auth.getUser(); - const { data, error } = await this.instrumentationService.startSpan({ + const { data: { user }, error } = await this.instrumentationService.startSpan({ name: "UsersRepository > getCurrentUser > supabase.auth.getUser", op: "db:query", attributes: { "system": "supabase.auth" }, @@ -190,14 +197,11 @@ export class UsersRepository implements IUsersRepository { throw new AuthenticationError("Failed to get current user"); } - if (!data) { + if (!user) { throw new NotFoundError("User not found"); } - return { - ...data, - id: data.user.id, - }; + return user; } catch (err) { this.crashReporterService.report(err); throw err; @@ -205,14 +209,21 @@ export class UsersRepository implements IUsersRepository { }) } - async createUser(input: CreateUser, tx?: ITransaction): Promise { + async createUser(input: ICreateUserSchema, tx?: ITransaction): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > createUser", }, async () => { try { + + console.log("Create User"); + const supabase = this.supabaseAdmin; - const query = supabase.auth.admin.createUser(input) + const query = supabase.auth.admin.createUser({ + email: input.email, + password: input.password, + email_confirm: true, + }) const { data: { user } } = await this.instrumentationService.startSpan({ name: "UsersRepository > createUser > supabase.auth.admin.createUser", @@ -237,14 +248,14 @@ export class UsersRepository implements IUsersRepository { }) } - async inviteUser(email: string, tx?: ITransaction): Promise { + async inviteUser(credential: ICredentialsInviteUserSchema, tx?: ITransaction): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > inviteUser", }, async () => { try { const supabase = this.supabaseAdmin; - const query = supabase.auth.admin.inviteUserByEmail(email); + const query = supabase.auth.admin.inviteUserByEmail(credential.email); const { data: { user } } = await this.instrumentationService.startSpan({ name: "UsersRepository > inviteUser > supabase.auth.admin.inviteUserByEmail", @@ -268,14 +279,14 @@ export class UsersRepository implements IUsersRepository { }) } - async updateUser(id: string, input: Partial, tx?: ITransaction): Promise { + async updateUser(credential: ICredentialUpdateUserSchema, input: Partial, tx?: ITransaction): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > updateUser", }, async () => { try { const supabase = this.supabaseAdmin; - const queryUpdateSupabaseUser = supabase.auth.admin.updateUserById(id, { + const queryUpdateSupabaseUser = supabase.auth.admin.updateUserById(credential.id, { email: input.email, email_confirm: input.email_confirmed_at, password: input.encrypted_password ?? undefined, @@ -303,7 +314,7 @@ export class UsersRepository implements IUsersRepository { const queryGetUser = db.users.findUnique({ where: { - id, + id: credential.id, }, include: { profile: true, @@ -326,7 +337,7 @@ export class UsersRepository implements IUsersRepository { const queryUpdateUser = db.users.update({ where: { - id, + id: credential.id, }, data: { role: input.role || user.role, @@ -369,7 +380,7 @@ export class UsersRepository implements IUsersRepository { return { ...updatedUser, - id, + id: credential.id, }; } catch (err) { @@ -379,14 +390,14 @@ export class UsersRepository implements IUsersRepository { }) } - async deleteUser(id: string, tx?: ITransaction): Promise { + async deleteUser(credential: ICredentialsDeleteUserSchema, tx?: ITransaction): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > deleteUser", }, async () => { try { const supabase = this.supabaseAdmin; - const query = supabase.auth.admin.deleteUser(id); + const query = supabase.auth.admin.deleteUser(credential.id); const { data: user, error } = await this.instrumentationService.startSpan({ name: "UsersRepository > deleteUser > supabase.auth.admin.deleteUser", @@ -404,7 +415,7 @@ export class UsersRepository implements IUsersRepository { return { ...user, - id + id: credential.id, }; } catch (err) { @@ -414,15 +425,15 @@ export class UsersRepository implements IUsersRepository { }) } - async banUser(id: string, ban_duration: string, tx?: ITransaction): Promise { + async banUser(credential: ICredentialsBanUserSchema, input: IBanUserSchema, tx?: ITransaction): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > banUser", }, async () => { try { const supabase = this.supabaseAdmin; - const query = supabase.auth.admin.updateUserById(id, { - ban_duration: ban_duration ?? "100h", + const query = supabase.auth.admin.updateUserById(credential.id, { + ban_duration: input.ban_duration ?? "24h", }) const { data: user, error } = await this.instrumentationService.startSpan({ @@ -441,7 +452,7 @@ export class UsersRepository implements IUsersRepository { return { ...user, - id + id: credential.id, }; } catch (err) { @@ -452,14 +463,14 @@ export class UsersRepository implements IUsersRepository { } - async unbanUser(id: string, tx?: ITransaction): Promise { + async unbanUser(credential: ICredentialsUnbanUserSchema, tx?: ITransaction): Promise { return await this.instrumentationService.startSpan({ name: "UsersRepository > unbanUser", }, async () => { try { const supabase = this.supabaseAdmin; - const query = supabase.auth.admin.updateUserById(id, { + const query = supabase.auth.admin.updateUserById(credential.id, { ban_duration: "none", }) @@ -479,7 +490,7 @@ export class UsersRepository implements IUsersRepository { return { ...user, - id + id: credential.id, }; } catch (err) { diff --git a/sigap-website/src/infrastructure/services/authentication.service.ts b/sigap-website/src/infrastructure/services/authentication.service.ts index f127c78..fb4f9ac 100644 --- a/sigap-website/src/infrastructure/services/authentication.service.ts +++ b/sigap-website/src/infrastructure/services/authentication.service.ts @@ -6,11 +6,13 @@ import { IAuthenticationService } from "@/src/application/services/authenticatio import { ICrashReporterService } from "@/src/application/services/crash-reporter.service.interface"; import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface"; import { AuthenticationError } from "@/src/entities/errors/auth"; +import { ISendMagicLinkSchema } from "@/src/entities/models/auth/send-magic-link.model"; +import { ISendPasswordRecoverySchema } from "@/src/entities/models/auth/send-password-recovery.model"; import { Session } from "@/src/entities/models/auth/session.model"; -import { SignInPasswordless, SignInWithPassword } from "@/src/entities/models/auth/sign-in.model"; -import { SignUpWithEmail, SignUpWithPhone } from "@/src/entities/models/auth/sign-up.model"; -import { VerifyOtpFormData } from "@/src/entities/models/auth/verify-otp.model"; -import { User } from "@/src/entities/models/users/users.model"; +import { SignInWithPasswordSchema, ISignInPasswordlessSchema, ISignInWithPasswordSchema } from "@/src/entities/models/auth/sign-in.model"; +import { SignUpWithEmailSchema, SignUpWithPhoneSchema, ISignUpWithEmailSchema, ISignUpWithPhoneSchema } from "@/src/entities/models/auth/sign-up.model"; +import { IVerifyOtpSchema } from "@/src/entities/models/auth/verify-otp.model"; +import { IUserSchema } from "@/src/entities/models/users/users.model"; export class AuthenticationService implements IAuthenticationService { constructor( @@ -21,7 +23,7 @@ export class AuthenticationService implements IAuthenticationService { private readonly supabaseServer = createClient() ) { } - async signInPasswordless(credentials: SignInPasswordless): Promise { + async signInPasswordless(credentials: ISignInPasswordlessSchema): Promise { return await this.instrumentationService.startSpan({ name: "signInPasswordless Use Case", }, async () => { @@ -49,9 +51,9 @@ export class AuthenticationService implements IAuthenticationService { }) } - async signInWithPassword(credentials: SignInWithPassword): Promise { + async SignInWithPasswordSchema(credentials: ISignInWithPasswordSchema): Promise { return await this.instrumentationService.startSpan({ - name: "signInWithPassword Use Case", + name: "SignInWithPasswordSchema Use Case", }, async () => { try { const supabase = await this.supabaseServer @@ -77,9 +79,9 @@ export class AuthenticationService implements IAuthenticationService { }) } - async signUpWithEmail(credentials: SignUpWithEmail): Promise { + async SignUpWithEmailSchema(credentials: ISignUpWithEmailSchema): Promise { return await this.instrumentationService.startSpan({ - name: "signUpWithEmail Use Case", + name: "SignUpWithEmailSchema Use Case", }, async () => { try { const supabase = await this.supabaseServer @@ -122,7 +124,7 @@ export class AuthenticationService implements IAuthenticationService { }) } - async signUpWithPhone(credentials: SignUpWithPhone): Promise { + async SignUpWithPhoneSchema(credentials: ISignUpWithPhoneSchema): Promise { throw new Error("Method not implemented."); } @@ -190,14 +192,14 @@ export class AuthenticationService implements IAuthenticationService { }) } - async sendMagicLink(email: string): Promise { + async sendMagicLink(credentials: ISendMagicLinkSchema): Promise { return await this.instrumentationService.startSpan({ name: "sendMagicLink Use Case", }, async () => { try { const supabase = await this.supabaseServer - const magicLink = supabase.auth.signInWithOtp({ email }) + const magicLink = supabase.auth.signInWithOtp({ email: credentials.email }) await this.instrumentationService.startSpan({ name: "supabase.auth.signIn", @@ -216,14 +218,14 @@ export class AuthenticationService implements IAuthenticationService { }) } - async sendPasswordRecovery(email: string): Promise { + async sendPasswordRecovery(credentials: ISendPasswordRecoverySchema): Promise { return await this.instrumentationService.startSpan({ name: "sendPasswordRecovery Use Case", }, async () => { try { const supabase = await this.supabaseServer - const passwordRecovery = supabase.auth.resetPasswordForEmail(email, { + const passwordRecovery = supabase.auth.resetPasswordForEmail(credentials.email, { redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`, }) @@ -244,7 +246,7 @@ export class AuthenticationService implements IAuthenticationService { }) } - async verifyOtp(credentials: VerifyOtpFormData): Promise { + async verifyOtp(credentials: IVerifyOtpSchema): Promise { return await this.instrumentationService.startSpan({ name: "verifyOtp Use Case", }, async () => { diff --git a/sigap-website/src/interface-adapters/controllers/auth/auth-controller.tsx b/sigap-website/src/interface-adapters/controllers/auth/auth-controller.tsx deleted file mode 100644 index 9d0c644..0000000 --- a/sigap-website/src/interface-adapters/controllers/auth/auth-controller.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useMutation } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { SignInFormData } from '@/src/entities/models/auth/sign-in.model'; -import { VerifyOtpFormData } from '@/src/entities/models/auth/verify-otp.model'; -import { useNavigations } from '@/app/_hooks/use-navigations'; -import { AuthenticationError } from '@/src/entities/errors/auth'; -import * as authRepository from '@/src/application/repositories/authentication.repository'; - -export function useAuthActions() { - const { router } = useNavigations(); - - // Sign In Mutation - const signInMutation = useMutation({ - mutationFn: async (data: SignInFormData) => { - return await authRepository.signIn(data); - }, - onSuccess: (result) => { - toast.success(result.message); - if (result.redirectTo && result.success) { - router.push(result.redirectTo); - } - }, - onError: (error) => { - if (error instanceof AuthenticationError) { - toast.error(`Authentication failed: ${error.message}`); - } else { - toast.error('Failed to sign in. Please try again later.'); - } - } - }); - - // Verify OTP Mutation - const verifyOtpMutation = useMutation({ - mutationFn: async (data: VerifyOtpFormData) => { - return await authRepository.verifyOtp(data); - }, - onSuccess: (result) => { - toast.success(result.message); - if (result.redirectTo) { - router.push(result.redirectTo); - } - }, - onError: (error) => { - if (error instanceof AuthenticationError) { - toast.error(`Verification failed: ${error.message}`); - } else { - toast.error('Failed to verify OTP. Please try again.'); - } - } - }); - - // Sign Out Mutation - const signOutMutation = useMutation({ - mutationFn: async () => { - return await authRepository.signOut(); - }, - onSuccess: (result) => { - toast.success(result.message); - if (result.redirectTo) { - router.push(result.redirectTo); - } - }, - onError: (error) => { - toast.error('Failed to sign out. Please try again.'); - } - }); - - // Password Recovery Mutation - const passwordRecoveryMutation = useMutation({ - mutationFn: async (email: string) => { - return await authRepository.sendPasswordRecovery(email); - }, - onSuccess: (result) => { - toast.success(result.message); - }, - onError: (error) => { - toast.error('Failed to send password recovery email. Please try again.'); - } - }); - - // Magic Link Mutation - const magicLinkMutation = useMutation({ - mutationFn: async (email: string) => { - return await authRepository.sendMagicLink(email); - }, - onSuccess: (result) => { - toast.success(result.message); - }, - onError: (error) => { - toast.error('Failed to send magic link email. Please try again.'); - } - }); - - return { - signIn: { - mutate: signInMutation.mutateAsync, - isPending: signInMutation.isPending, - error: signInMutation.error, - }, - verifyOtp: { - mutate: verifyOtpMutation.mutateAsync, - isPending: verifyOtpMutation.isPending, - error: verifyOtpMutation.error, - }, - signOut: { - mutate: signOutMutation.mutateAsync, - isPending: signOutMutation.isPending, - error: signOutMutation.error, - }, - passwordRecovery: { - mutate: passwordRecoveryMutation.mutateAsync, - isPending: passwordRecoveryMutation.isPending, - error: passwordRecoveryMutation.error, - }, - magicLink: { - mutate: magicLinkMutation.mutateAsync, - isPending: magicLinkMutation.isPending, - error: magicLinkMutation.error, - } - }; -} \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/auth/send-magic-link.controller.ts b/sigap-website/src/interface-adapters/controllers/auth/send-magic-link.controller.ts new file mode 100644 index 0000000..e68c641 --- /dev/null +++ b/sigap-website/src/interface-adapters/controllers/auth/send-magic-link.controller.ts @@ -0,0 +1,32 @@ +import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface" +import { ISendMagicLinkUseCase } from "@/src/application/use-cases/auth/send-magic-link.use-case" + +import { z } from "zod" + +import { InputParseError } from "@/src/entities/errors/common" + +const sendMagicLinkInputSchema = z.object({ + email: z.string().email("Please enter a valid email address"), +}) + +export type ISendMagicLinkController = ReturnType + +export const sendMagicLinkController = + ( + instrumentationService: IInstrumentationService, + sendMagicLinkUseCase: ISendMagicLinkUseCase + ) => + async (input: Partial>) => { + return await instrumentationService.startSpan({ name: "sendMagicLink Controller" }, + async () => { + const { data, error: inputParseError } = sendMagicLinkInputSchema.safeParse(input) + + if (inputParseError) { + throw new InputParseError("Invalid data", { cause: inputParseError }) + } + + return await sendMagicLinkUseCase({ + email: data.email + }) + }) + } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/auth/send-password-recovery.controller.ts b/sigap-website/src/interface-adapters/controllers/auth/send-password-recovery.controller.ts new file mode 100644 index 0000000..6d6d57f --- /dev/null +++ b/sigap-website/src/interface-adapters/controllers/auth/send-password-recovery.controller.ts @@ -0,0 +1,30 @@ +import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface" +import { ISendPasswordRecoveryUseCase } from "@/src/application/use-cases/auth/send-password-recovery.use-case" +import { InputParseError } from "@/src/entities/errors/common" +import { z } from "zod" + +const sendPasswordRecoveryInputSchema = z.object({ + email: z.string().email("Please enter a valid email address"), +}) + +export type ISendPasswordRecoveryController = ReturnType + +export const sendPasswordRecoveryController = + ( + instrumentationService: IInstrumentationService, + sendPasswordRecoveryUseCase: ISendPasswordRecoveryUseCase + ) => + async (input: Partial>) => { + return await instrumentationService.startSpan({ name: "sendPasswordRecovery Controller" }, + async () => { + const { data, error: inputParseError } = sendPasswordRecoveryInputSchema.safeParse(input) + + if (inputParseError) { + throw new InputParseError("Invalid data", { cause: inputParseError }) + } + + return await sendPasswordRecoveryUseCase({ + email: data.email + }) + }) + } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/auth/sign-in.controller.tsx b/sigap-website/src/interface-adapters/controllers/auth/sign-in.controller.tsx index 39610b9..1463d61 100644 --- a/sigap-website/src/interface-adapters/controllers/auth/sign-in.controller.tsx +++ b/sigap-website/src/interface-adapters/controllers/auth/sign-in.controller.tsx @@ -5,7 +5,7 @@ import { InputParseError } from "@/src/entities/errors/common"; // Sign In Controller const signInInputSchema = z.object({ - email: z.string().email("Please enter a valid email address"), + email: z.string().min(1, "Email is Required").email("Please enter a valid email address"), }) export type ISignInController = ReturnType @@ -20,7 +20,7 @@ export const signInController = const { data, error: inputParseError } = signInInputSchema.safeParse(input) if (inputParseError) { - throw new InputParseError("Invalid data", { cause: inputParseError }) + throw new InputParseError(inputParseError.errors[0].message) } return await signInUseCase({ diff --git a/sigap-website/src/interface-adapters/controllers/users/ban-user.controller.ts b/sigap-website/src/interface-adapters/controllers/users/ban-user.controller.ts index 6cca05e..921cff2 100644 --- a/sigap-website/src/interface-adapters/controllers/users/ban-user.controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/ban-user.controller.ts @@ -1,27 +1,38 @@ import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface"; import { IBanUserUseCase } from "@/src/application/use-cases/users/ban-user.use-case"; +import { IGetCurrentUserUseCase } from "@/src/application/use-cases/users/get-current-user.use-case"; import { InputParseError } from "@/src/entities/errors/common"; +import { BanDurationSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model"; import { z } from "zod"; const inputSchema = z.object({ - id: z.string(), - ban_duration: z.string() + ban_duration: BanDurationSchema }) export type IBanUserController = ReturnType export const banUserController = ( instrumentationService: IInstrumentationService, - banUserUseCase: IBanUserUseCase + banUserUseCase: IBanUserUseCase, + getCurrentUserUseCase: IGetCurrentUserUseCase ) => - async (input: Partial>) => { + async (credential: ICredentialsBanUserSchema, input: Partial>) => { return await instrumentationService.startSpan({ name: "banUser Controller" }, async () => { + + const session = await getCurrentUserUseCase(); + + if (!session) { + throw new InputParseError("Must be logged in to ban a user"); + } + const { data, error: inputParseError } = inputSchema.safeParse(input); if (inputParseError) { throw new InputParseError("Invalid data", { cause: inputParseError }); } - return await banUserUseCase(data.id, data.ban_duration); + console.log("Controller: Ban User"); + + return await banUserUseCase({ id: credential.id }, { ban_duration: data.ban_duration }); }) } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/users/create-user.controller.ts b/sigap-website/src/interface-adapters/controllers/users/create-user.controller.ts index d20cd33..d05aa17 100644 --- a/sigap-website/src/interface-adapters/controllers/users/create-user.controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/create-user.controller.ts @@ -2,11 +2,24 @@ import { IUsersRepository } from "@/src/application/repositories/users.repositor import { IAuthenticationService } from "@/src/application/services/authentication.service.interface" import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface" import { ICreateUserUseCase } from "@/src/application/use-cases/users/create-user.use-case" +import { IGetCurrentUserUseCase } from "@/src/application/use-cases/users/get-current-user.use-case" import { UnauthenticatedError } from "@/src/entities/errors/auth" import { InputParseError } from "@/src/entities/errors/common" -import { CreateUserSchema } from "@/src/entities/models/users/users.model" +import { CreateUserSchema } from "@/src/entities/models/users/create-user.model" import { z } from "zod" +// const inputSchema = z.object({ +// email: z.string().min(1, { message: "Email is required" }).email({ message: "Please enter a valid email address" }), +// password: z +// .string() +// .min(1, { message: "Password is required" }) +// .min(8, { message: "Password must be at least 8 characters" }) +// .regex(/[A-Z]/, { message: "Password must contain at least one uppercase letter" }) +// .regex(/[a-z]/, { message: "Password must contain at least one lowercase letter" }) +// .regex(/[0-9]/, { message: "Password must contain at least one number" }), +// email_confirm: z.boolean().optional(), +// }) + const inputSchema = CreateUserSchema export type ICreateUserController = ReturnType @@ -14,20 +27,23 @@ export type ICreateUserController = ReturnType export const createUserController = ( instrumentationService: IInstrumentationService, createUserUseCase: ICreateUserUseCase, - authenticationService: IAuthenticationService + getCurrentUserUseCase: IGetCurrentUserUseCase ) => async (input: Partial>) => { + return await instrumentationService.startSpan({ name: "createUser Controller" }, async () => { - const session = await authenticationService.getSession() + const session = await getCurrentUserUseCase() - if (!session) { - throw new UnauthenticatedError("Must be logged in to create a todo") - } + if (!session) { + throw new UnauthenticatedError("Must be logged in to create a user") + } - const { data, error: inputParseError } = inputSchema.safeParse(input) + const { data, error: inputParseError } = inputSchema.safeParse(input) - if (inputParseError) { - throw new InputParseError("Invalid data", { cause: inputParseError }) - } + if (inputParseError) { + throw new InputParseError(inputParseError.errors[0].message) + } - return await createUserUseCase(data); + return await createUserUseCase(data); + + }) } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/users/delete-user.controller.ts b/sigap-website/src/interface-adapters/controllers/users/delete-user.controller.ts index c3db458..76fc205 100644 --- a/sigap-website/src/interface-adapters/controllers/users/delete-user.controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/delete-user.controller.ts @@ -1,7 +1,10 @@ +import { IUsersRepository } from "@/src/application/repositories/users.repository.interface" import { IAuthenticationService } from "@/src/application/services/authentication.service.interface" import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface" import { IDeleteUserUseCase } from "@/src/application/use-cases/users/delete-user.use-case" +import { IGetCurrentUserUseCase } from "@/src/application/use-cases/users/get-current-user.use-case" import { UnauthenticatedError } from "@/src/entities/errors/auth" +import { ICredentialsDeleteUserSchema } from "@/src/entities/models/users/delete-user.model" export type IDeleteUserController = ReturnType @@ -9,17 +12,17 @@ export const deleteUserController = ( instrumentationService: IInstrumentationService, deleteUserUseCase: IDeleteUserUseCase, - authenticationService: IAuthenticationService + getCurrentUserUseCase: IGetCurrentUserUseCase ) => - async (id: string) => { + async (credential: ICredentialsDeleteUserSchema) => { return await instrumentationService.startSpan({ name: "deleteUser Controller" }, async () => { - const session = await authenticationService.getSession() + const session = await getCurrentUserUseCase() if (!session) { - throw new UnauthenticatedError("Must be logged in to create a todo") + throw new UnauthenticatedError("Must be logged in to create a user") } - return await deleteUserUseCase(id); + return await deleteUserUseCase(credential); }) } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/users/get-user-by-email.controller.ts b/sigap-website/src/interface-adapters/controllers/users/get-user-by-email.controller.ts index 14f827d..80893ab 100644 --- a/sigap-website/src/interface-adapters/controllers/users/get-user-by-email.controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/get-user-by-email.controller.ts @@ -22,6 +22,6 @@ export const getUserByEmailController = throw new InputParseError("Invalid data", { cause: inputParseError }); } - return await getUserByEmailUseCase(data.email); + return await getUserByEmailUseCase({ email: data.email }); }) } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/users/get-user-by-id.controller.ts b/sigap-website/src/interface-adapters/controllers/users/get-user-by-id.controller.ts index 1f5ef5d..d637bff 100644 --- a/sigap-website/src/interface-adapters/controllers/users/get-user-by-id.controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/get-user-by-id.controller.ts @@ -21,6 +21,6 @@ export const getUserByIdController = ( throw new InputParseError("Invalid data", { cause: inputParseError }); } - return await getUserByIdUseCase(data.id); + return await getUserByIdUseCase({ id: data.id }); }) } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/users/get-user-by-username.controller.ts b/sigap-website/src/interface-adapters/controllers/users/get-user-by-username.controller.ts index beaa776..4b422d2 100644 --- a/sigap-website/src/interface-adapters/controllers/users/get-user-by-username.controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/get-user-by-username.controller.ts @@ -22,6 +22,6 @@ export const getUserByUsernameController = throw new InputParseError("Invalid data", { cause: inputParseError }); } - return await getUserByUsernameUseCase(data.username); + return await getUserByUsernameUseCase({ username: data.username }); }) } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/users/get-users.controller.ts b/sigap-website/src/interface-adapters/controllers/users/get-users.controller.ts index 6f98b9c..87c2a7a 100644 --- a/sigap-website/src/interface-adapters/controllers/users/get-users.controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/get-users.controller.ts @@ -1,15 +1,16 @@ import { IUsersRepository } from "@/src/application/repositories/users.repository.interface" import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface" +import { IGetUsersUseCase } from "@/src/application/use-cases/users/get-users.use-case" -export type IGetUserController = ReturnType +export type IGetUsersController = ReturnType export const getUsersController = ( instrumentationService: IInstrumentationService, - usersRepository: IUsersRepository + getUsersUseCase: IGetUsersUseCase ) => async () => { - return await instrumentationService.startSpan({ name: "getgetUsers Controller" }, async () => { - return await usersRepository.getUsers(); + return await instrumentationService.startSpan({ name: "geIGetUsers Controller" }, async () => { + return await getUsersUseCase() }) } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/users/invite-user.controller.ts b/sigap-website/src/interface-adapters/controllers/users/invite-user.controller.ts index f8d5572..a6577f0 100644 --- a/sigap-website/src/interface-adapters/controllers/users/invite-user.controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/invite-user.controller.ts @@ -1,4 +1,5 @@ import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface"; +import { IGetCurrentUserUseCase } from "@/src/application/use-cases/users/get-current-user.use-case"; import { IInviteUserUseCase } from "@/src/application/use-cases/users/invite-user.use-case"; import { InputParseError } from "@/src/entities/errors/common"; import { z } from "zod"; @@ -12,17 +13,25 @@ export type IInviteUserController = ReturnType export const inviteUserController = ( instrumentationService: IInstrumentationService, - inviteUserUseCase: IInviteUserUseCase + inviteUserUseCase: IInviteUserUseCase, + getCurrentUserUseCase: IGetCurrentUserUseCase ) => async (input: Partial>) => { return await instrumentationService.startSpan({ name: "inviteUser Controller" }, async () => { + + const session = await getCurrentUserUseCase(); + + if (!session) { + throw new InputParseError("Must be logged in to invite a user"); + } + const { data, error: inputParseError } = inputSchema.safeParse(input); if (inputParseError) { throw new InputParseError("Invalid data", { cause: inputParseError }); } - return await inviteUserUseCase(data.email); + return await inviteUserUseCase({ email: data.email }); }) } diff --git a/sigap-website/src/interface-adapters/controllers/users/unban-user.controller.ts b/sigap-website/src/interface-adapters/controllers/users/unban-user.controller.ts index 4e119a7..71d7fbd 100644 --- a/sigap-website/src/interface-adapters/controllers/users/unban-user.controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/unban-user.controller.ts @@ -1,4 +1,5 @@ import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface"; +import { IGetCurrentUserUseCase } from "@/src/application/use-cases/users/get-current-user.use-case"; import { IUnbanUserUseCase } from "@/src/application/use-cases/users/unban-user.use-case"; import { InputParseError } from "@/src/entities/errors/common"; import { z } from "zod"; @@ -11,16 +12,24 @@ export type IUnbanUserController = ReturnType export const unbanUserController = ( instrumentationService: IInstrumentationService, - unbanUserUseCase: IUnbanUserUseCase + unbanUserUseCase: IUnbanUserUseCase, + getCurrentUserUseCase: IGetCurrentUserUseCase ) => async (input: Partial>) => { return await instrumentationService.startSpan({ name: "unbanUser Controller" }, async () => { + + const session = await getCurrentUserUseCase(); + + if (!session) { + throw new InputParseError("Must be logged in to unban a user"); + } + const { data, error: inputParseError } = inputSchema.safeParse(input); if (inputParseError) { throw new InputParseError("Invalid data", { cause: inputParseError }); } - return await unbanUserUseCase(data.id); + return await unbanUserUseCase({ id: data.id }); }) } \ No newline at end of file diff --git a/sigap-website/src/interface-adapters/controllers/users/update-user-controller.ts b/sigap-website/src/interface-adapters/controllers/users/update-user-controller.ts index 82e8367..72d40f1 100644 --- a/sigap-website/src/interface-adapters/controllers/users/update-user-controller.ts +++ b/sigap-website/src/interface-adapters/controllers/users/update-user-controller.ts @@ -1,9 +1,11 @@ import { IAuthenticationService } from "@/src/application/services/authentication.service.interface"; import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface"; +import { IGetCurrentUserUseCase } from "@/src/application/use-cases/users/get-current-user.use-case"; import { IUpdateUserUseCase } from "@/src/application/use-cases/users/update-user.use-case"; import { UnauthenticatedError } from "@/src/entities/errors/auth"; import { InputParseError } from "@/src/entities/errors/common"; -import { UpdateUser, UpdateUserSchema } from "@/src/entities/models/users/users.model"; +import { UpdateUserSchema } from "@/src/entities/models/users/update-user.model"; + import { z } from "zod"; const inputSchema = UpdateUserSchema @@ -14,12 +16,12 @@ export const updateUserController = ( instrumentationService: IInstrumentationService, updateUserUseCase: IUpdateUserUseCase, - authenticationService: IAuthenticationService + getCurrentUserUseCase: IGetCurrentUserUseCase ) => async (id: string, input: Partial>,) => { return await instrumentationService.startSpan({ name: "updateUser Controller" }, async () => { - const session = await authenticationService.getSession() + const session = await getCurrentUserUseCase() if (!session) { throw new UnauthenticatedError("Must be logged in to create a todo") @@ -31,6 +33,6 @@ export const updateUserController = throw new InputParseError("Invalid data", { cause: inputParseError }) } - return await updateUserUseCase(id, data); + return await updateUserUseCase({ id }, data); }) } \ No newline at end of file