From c18a9da0d320e9c8ab535fd694b2e33a6b8a6f7f Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 30 Dec 2025 13:38:01 +0100 Subject: [PATCH] Refactoring user Page in admin panel. --- .../dashboard/(admin)/admin/users/page.tsx | 27 ++- src/components/emails/email-create-user.tsx | 42 ++++ .../admin/users/admin-user-add-modal.tsx | 35 ++++ .../admin-user-change-password-modal.tsx | 65 ++++++ .../users/admin-user-change-role-modal.tsx | 91 +++++++++ .../admin/users/admin-user-delete-modal.tsx | 75 +++++++ .../admin/users/admin-user-edit-form.tsx | 100 +++++++++ .../admin/users/admin-user-edit-modal.tsx | 43 ++++ .../dashboard/admin/users/admin-user-form.tsx | 95 +++++++++ .../dashboard/admin/users/admin-user-list.tsx | 12 ++ .../dashboard/admin/users/table-colums.tsx | 91 +++++++++ .../admin/users/user-actions-cell.tsx | 78 +++++++ .../dashboard/admin/users/user.action.ts | 191 ++++++++++++++++++ .../dashboard/admin/users/user.schema.ts | 16 ++ .../accounts/table-columns.tsx | 0 .../{users => users2}/admin-user-table.tsx | 0 .../{users => users2}/button-delete-use.tsx | 0 .../admin/{users => users2}/columns-users.tsx | 0 .../sessions/table-columns.tsx | 0 .../profile/components/avatar-with-upload.tsx | 2 +- .../profile2/avatar/avatar-with-upload.tsx | 80 -------- .../profile2/avatar/avatar.action.ts | 15 -- .../button-delete-account.tsx | 2 +- .../delete-account.action.ts | 2 +- .../profile2/user-avatar/user-avatar.tsx | 22 -- .../profile2/user-form/user-form.tsx | 122 ----------- src/db/schema/02_user.ts | 4 + src/db/services/user.ts | 34 ++++ src/types/auth.ts | 2 +- src/utils/name-from-email.ts | 11 + src/utils/password.ts | 27 +++ 31 files changed, 1032 insertions(+), 252 deletions(-) create mode 100644 src/components/emails/email-create-user.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/admin-user-add-modal.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/admin-user-change-password-modal.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/admin-user-change-role-modal.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/admin-user-delete-modal.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/admin-user-edit-form.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/admin-user-edit-modal.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/admin-user-form.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/admin-user-list.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/table-colums.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/user-actions-cell.tsx create mode 100644 src/components/wrappers/dashboard/admin/users/user.action.ts create mode 100644 src/components/wrappers/dashboard/admin/users/user.schema.ts rename src/components/wrappers/dashboard/admin/{users => users2}/accounts/table-columns.tsx (100%) rename src/components/wrappers/dashboard/admin/{users => users2}/admin-user-table.tsx (100%) rename src/components/wrappers/dashboard/admin/{users => users2}/button-delete-use.tsx (100%) rename src/components/wrappers/dashboard/admin/{users => users2}/columns-users.tsx (100%) rename src/components/wrappers/dashboard/admin/{users => users2}/sessions/table-columns.tsx (100%) delete mode 100644 src/components/wrappers/dashboard/profile2/avatar/avatar-with-upload.tsx delete mode 100644 src/components/wrappers/dashboard/profile2/avatar/avatar.action.ts delete mode 100644 src/components/wrappers/dashboard/profile2/user-avatar/user-avatar.tsx delete mode 100644 src/components/wrappers/dashboard/profile2/user-form/user-form.tsx create mode 100644 src/db/services/user.ts create mode 100644 src/utils/name-from-email.ts create mode 100644 src/utils/password.ts diff --git a/app/(customer)/dashboard/(admin)/admin/users/page.tsx b/app/(customer)/dashboard/(admin)/admin/users/page.tsx index fcffb2c5..9e662386 100644 --- a/app/(customer)/dashboard/(admin)/admin/users/page.tsx +++ b/app/(customer)/dashboard/(admin)/admin/users/page.tsx @@ -1,9 +1,9 @@ import {PageParams} from "@/types/next"; -import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {db} from "@/db"; -import {DataTable} from "@/components/wrappers/common/table/data-table"; -import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/users/columns-users"; -import {isNull} from "drizzle-orm"; +import {desc, isNull} from "drizzle-orm"; +import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list"; +import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal"; export default async function RoutePage(props: PageParams<{}>) { @@ -11,7 +11,14 @@ export default async function RoutePage(props: PageParams<{}>) { where: (fields) => isNull(fields.deletedAt), with: { accounts: true - } + }, + orderBy: (fields) => desc(fields.createdAt), + + }); + const organizations = await db.query.organization.findMany({ + with: { + members: true, + }, }); return ( @@ -19,14 +26,16 @@ export default async function RoutePage(props: PageParams<{}>) {
Active users + + +
- + ); } + + diff --git a/src/components/emails/email-create-user.tsx b/src/components/emails/email-create-user.tsx new file mode 100644 index 00000000..4e6ebf3f --- /dev/null +++ b/src/components/emails/email-create-user.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import EmailLayout from "./email-layout"; +import {Heading, Text, Section, Button} from "@react-email/components"; +import {getServerUrl} from "@/utils/get-server-url"; +import {env} from "@/env.mjs"; + +interface EmailCreateUserProps { + email: string; + password: string; +} + +export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => { + const baseUrl = getServerUrl(); + + + return ( + + + Your account on {env.PROJECT_NAME} has just been created! + + + + Email: {email} + + + + Default password: {password} + + +
+ +
+
+ ); +}; + +export default EmailCreateUser; diff --git a/src/components/wrappers/dashboard/admin/users/admin-user-add-modal.tsx b/src/components/wrappers/dashboard/admin/users/admin-user-add-modal.tsx new file mode 100644 index 00000000..d8e1533e --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/admin-user-add-modal.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useState } from "react"; +import { Plus } from "lucide-react"; + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Organization } from "@/db/schema/03_organization"; +import {AdminUserForm} from "@/components/wrappers/dashboard/admin/users/admin-user-form"; + +type AdminUserAddModalProps = { + organizations: Organization[]; +}; + +export const AdminUserAddModal = ({ organizations }: AdminUserAddModalProps) => { + + const [open, setOpen] = useState(false); + + return ( + + + + + + + Create a new user + To create a new user please provide following informations + setOpen(false)} /> + + + + ); +}; diff --git a/src/components/wrappers/dashboard/admin/users/admin-user-change-password-modal.tsx b/src/components/wrappers/dashboard/admin/users/admin-user-change-password-modal.tsx new file mode 100644 index 00000000..bd8b4e75 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/admin-user-change-password-modal.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { User } from "@/db/schema/02_user"; +import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { requestPasswordReset } from "@/lib/auth/auth-client"; +import { toast } from "sonner"; + +type AdminUserChangePasswordProps = { + open: boolean; + user: User; + onOpenChange: (open: boolean) => void; +}; + +export const AdminUserChangePassword = ({ user, open, onOpenChange }: AdminUserChangePasswordProps) => { + + const mutation = useMutation({ + mutationFn: async () => { + await requestPasswordReset( + { + email: user.email, + + }, + { + onSuccess: () => { + toast.success("Reset password request successfully sent!"); + onOpenChange(false); + }, + onError: (error) => { + toast.error(error.error.message); + }, + } + ); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + return ( + + + + Change {user.name}'s password + This action will send an email to the user to reset their password. + + + Cancel + await mutation.mutateAsync()} isPending={mutation.isPending}> + Validate + + + + + ); +}; diff --git a/src/components/wrappers/dashboard/admin/users/admin-user-change-role-modal.tsx b/src/components/wrappers/dashboard/admin/users/admin-user-change-role-modal.tsx new file mode 100644 index 00000000..d5e19e6b --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/admin-user-change-role-modal.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { authClient } from "@/lib/auth/auth-client"; +import { User } from "@/db/schema/02_user"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading"; + +type AdminUserChangeRoleModalProps = { + open: boolean; + user: User; + onOpenChange: (open: boolean) => void; +}; + +export const AdminUserChangeRoleModal = (props: AdminUserChangeRoleModalProps) => { + + + const { user, open, onOpenChange } = props; + const router = useRouter(); + const [role, setRole] = useState(user.role); + + const mutation = useMutation({ + mutationFn: async () => { + await authClient.admin.setRole( + { + userId: user.id, + // @ts-ignore + role: role, + }, + { + onSuccess: async (response) => { + toast.success("User role changed successfully."); + onOpenChange(false); + router.refresh(); + }, + onError: async (error) => { + console.error("AdminUserChangeRoleModal - setRole", error); + toast.error("An error occurred while updating user roles."); + onOpenChange(false); + }, + } + ); + }, + }); + + return ( + + + + Change the user's role + Modify this user's role within your organization. + + + +
+ { + onOpenChange(false); + }} + > + Cancel + + { + await mutation.mutateAsync(); + }} + > + Validate + +
+
+
+
+ ); +}; diff --git a/src/components/wrappers/dashboard/admin/users/admin-user-delete-modal.tsx b/src/components/wrappers/dashboard/admin/users/admin-user-delete-modal.tsx new file mode 100644 index 00000000..67df8256 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/admin-user-delete-modal.tsx @@ -0,0 +1,75 @@ +"use client"; + +import {useMutation} from "@tanstack/react-query"; +import {User} from "@/db/schema/02_user"; +import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; +import {useRouter} from "next/navigation"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import {authClient} from "@/lib/auth/auth-client"; +import {toast} from "sonner"; +import {setSuperAdminOwnerOfOrganizationsOwnedByUser} from "@/components/wrappers/dashboard/admin/users/user.action"; + +type AdminDeleteUserModalProps = { + open: boolean; + user: User; + onOpenChange: (open: boolean) => void; +}; + +export const AdminDeleteUserModal = ({user, open, onOpenChange}: AdminDeleteUserModalProps) => { + const router = useRouter(); + + const mutation = useMutation({ + mutationFn: async () => { + const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({userId: user.id}); + const result = res?.data; + if (result?.success) { + await authClient.admin.removeUser( + { + userId: user.id, + }, + { + onSuccess: async (response) => { + toast.success(`User ${user.name} successfully deleted`); + onOpenChange(false); + router.refresh(); + }, + onError: async (error) => { + toast.error("An error has occurred while deleting user"); + onOpenChange(false); + }, + } + ); + } else { + toast.error("An error has occurred while deleting user"); + } + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + return ( + + + + Are you sure you want to delete {user.name} ? + This action is irreversible: it will lead to the deletion of the user's + data. + + + Cancel + await mutation.mutateAsync()}>Confirm + + + + ); +}; diff --git a/src/components/wrappers/dashboard/admin/users/admin-user-edit-form.tsx b/src/components/wrappers/dashboard/admin/users/admin-user-edit-form.tsx new file mode 100644 index 00000000..a5e6ab60 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/admin-user-edit-form.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading"; +import {Button} from "@/components/ui/button"; +import {UserEditSchema, UserEditType, UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema"; +import {updateUserAction} from "@/components/wrappers/dashboard/admin/users/user.action"; +type AdminUserEditFormProps = { + onSuccess?: () => void; + defaultValues: { + id: string; + } & UserEditType; +}; + +export const AdminUserEditForm = ({ onSuccess, defaultValues }: AdminUserEditFormProps) => { + + + const router = useRouter(); + const form = useZodForm({ + schema: defaultValues ? UserEditSchema : UserSchema, + defaultValues: { + name: defaultValues.name, + email: defaultValues.email, + }, + }); + + const onCancel = () => { + form.reset(); + onSuccess?.(); + }; + + const mutation = useMutation({ + mutationFn: async (data: UserEditType) => { + const result = await updateUserAction({ + ...data, + id: defaultValues?.id || "", + }); + console.log(result) + const inner = result?.data; + if (inner?.success) { + toast.success("User Successfully updated"); + onSuccess?.(); + router.refresh(); + } else { + toast.error("An error occurred"); + onSuccess?.(); + } + }, + }); + + return ( +
{ + await mutation.mutateAsync(values); + }} + > + ( + + Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + +
+ + Validate +
+ + ); +}; diff --git a/src/components/wrappers/dashboard/admin/users/admin-user-edit-modal.tsx b/src/components/wrappers/dashboard/admin/users/admin-user-edit-modal.tsx new file mode 100644 index 00000000..98f5d13a --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/admin-user-edit-modal.tsx @@ -0,0 +1,43 @@ +"use client"; + +import {User} from "@/db/schema/02_user"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import {AdminUserEditForm} from "./admin-user-edit-form"; + +type AdminUserEditPasswordProps = { + open: boolean; + user: User; + onOpenChange: (open: boolean) => void; +}; + +export const AdminUserEdit = ({user, open, onOpenChange}: AdminUserEditPasswordProps) => { + + return ( + + + + Edit ${user.name}'s profile + Update following information + + e.preventDefault()} + > + onOpenChange(false)} + /> + + + + ); +}; diff --git a/src/components/wrappers/dashboard/admin/users/admin-user-form.tsx b/src/components/wrappers/dashboard/admin/users/admin-user-form.tsx new file mode 100644 index 00000000..b3032994 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/admin-user-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +import {useRouter} from "next/navigation"; +import {useMutation} from "@tanstack/react-query"; +import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; +import {Organization} from "@/db/schema/03_organization"; +import {Input} from "@/components/ui/input"; +import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; +import {Button} from "@/components/ui/button"; +import {UserSchema, UserType} from "@/components/wrappers/dashboard/admin/users/user.schema"; +import {toast} from "sonner"; +import {createUserAction} from "@/components/wrappers/dashboard/admin/users/user.action"; + +type AdminUserFormProps = { + onSuccess?: () => void; + organizations: Organization[]; +}; + +export const AdminUserForm = ({onSuccess, organizations}: AdminUserFormProps) => { + + + const router = useRouter(); + const form = useZodForm({ + schema: UserSchema, + }); + + + const onCancel = () => { + form.reset(); + onSuccess?.(); + }; + + const mutation = useMutation({ + mutationFn: async (data: UserType) => { + const result = await createUserAction(data); + const inner = result?.data; + if (inner?.success) { + toast.success("User Successfully created"); + onSuccess?.(); + router.refresh(); + } else { + toast.error("An error occurred"); + onSuccess?.(); + } + }, + }); + + return ( +
{ + await mutation.mutateAsync(values); + }} + > + ( + + Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + +
+ + + Validate + +
+ + ); +}; diff --git a/src/components/wrappers/dashboard/admin/users/admin-user-list.tsx b/src/components/wrappers/dashboard/admin/users/admin-user-list.tsx new file mode 100644 index 00000000..de0b9550 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/admin-user-list.tsx @@ -0,0 +1,12 @@ +"use client"; +import { DataTable } from "@/components/wrappers/common/table/data-table"; +import {usersListColumns} from "@/components/wrappers/dashboard/admin/users/table-colums"; +import {User} from "@/db/schema/02_user"; + +type AdminUserListProps = { + users: User[]; +}; + +export const AdminUserList = ({ users }: AdminUserListProps) => { + return ; +}; diff --git a/src/components/wrappers/dashboard/admin/users/table-colums.tsx b/src/components/wrappers/dashboard/admin/users/table-colums.tsx new file mode 100644 index 00000000..74a5ecd5 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/table-colums.tsx @@ -0,0 +1,91 @@ +import {ColumnDef} from "@tanstack/react-table"; +import {User} from "@/db/schema/02_user"; +import {Badge} from "@/components/ui/badge"; +import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"; +import {formatLocalizedDate, timeAgo} from "@/utils/date-formatting"; +import {Tooltip, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; +import {TooltipContent} from "@radix-ui/react-tooltip"; +import {Info} from "lucide-react"; +import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table"; +import {UserActionsCell} from "@/components/wrappers/dashboard/admin/users/user-actions-cell"; + +export function usersListColumns(): ColumnDef[] { + + return [ + { + accessorKey: "name", + header: "Profile", + cell: ({row}) => { + + return ( + + + +
+ + + + {row.original.name + .split(" ") + .map((n) => n[0]) + .join("")} + + +
+
{row.original.name}
+
+
+
+ + + + + Joined + {formatLocalizedDate(row.original.createdAt)} + + {/*{row.original.lastConnectedAt && (*/} + {/* */} + {/* Last connected*/} + {/* */} + {/* {formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt, locale)})*/} + {/* */} + {/* */} + {/*)}*/} + {row.original.lastChangedPasswordAt && ( + + Last password change + + {formatLocalizedDate(row.original.lastChangedPasswordAt)} ({timeAgo(row.original.lastChangedPasswordAt)}) + + + )} + +
+
+
+
+ ); + }, + }, + { + accessorKey: "email", + header: "Email", + }, + { + accessorKey: "role", + header: "Role", + cell: ({row}) => { + const role = row.original.role!; + return {role}; + }, + }, + { + id: "actions", + header: "Actions", + cell: ({row}) => , + }, + ]; +} diff --git a/src/components/wrappers/dashboard/admin/users/user-actions-cell.tsx b/src/components/wrappers/dashboard/admin/users/user-actions-cell.tsx new file mode 100644 index 00000000..1c677b89 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/user-actions-cell.tsx @@ -0,0 +1,78 @@ +"use client"; + +import {useState} from "react"; +import {Button} from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import {MoreHorizontal, Settings, Trash2, RotateCcwKey, UserCog} from "lucide-react"; +import {authClient} from "@/lib/auth/auth-client"; +import {cn} from "@/lib/utils"; +import {User} from "@/db/schema/02_user"; +import {AdminUserChangePassword} from "./admin-user-change-password-modal"; +import {AdminUserEdit} from "./admin-user-edit-modal"; +import {AdminUserChangeRoleModal} from "@/components/wrappers/dashboard/admin/users/admin-user-change-role-modal"; +import {AdminDeleteUserModal} from "@/components/wrappers/dashboard/admin/users/admin-user-delete-modal"; + +interface UserActionsCellProps { + user: User; +} + +export function UserActionsCell({user}: UserActionsCellProps) { + + const [isModalOpen, setIsModalOpen] = useState(false); + const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false); + const [isModalEditUserOpen, setIsModalEditUserOpen] = useState(false); + const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false); + const {data: session, isPending, error} = authClient.useSession(); + if (isPending || error) return null; + const isCurrentUser = session?.user?.id === user.id; + const isSuperAdmin = session?.user?.role === "superadmin"; + + if (isCurrentUser || user.role === "superadmin") return null; + + return ( + <> + + + + +
+ + + + + + + setIsModalOpen(true)}> + + Role + + setIsModalEditUserOpen(true)}> + + Edit User + + {isSuperAdmin && ( + <> + + setIsModalDeleteOpen(true)} className="text-red-600"> + + Delete + + + )} + + +
+ + ); +} diff --git a/src/components/wrappers/dashboard/admin/users/user.action.ts b/src/components/wrappers/dashboard/admin/users/user.action.ts new file mode 100644 index 00000000..46b5494d --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/user.action.ts @@ -0,0 +1,191 @@ +"use server"; + +import * as drizzleDb from "@/db"; +import {ServerActionResult} from "@/types/action-type"; +import {render} from "@react-email/render"; +import {UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema"; +import {extractNameFromEmail} from "@/utils/name-from-email"; +import {generateValidPassword} from "@/utils/password"; +import {auth} from "@/lib/auth/auth"; +import {z} from "zod"; +import {Organization} from "@/db/schema/03_organization"; +import {db} from "@/db"; +import {and, eq} from "drizzle-orm"; + +import {zEmail, zString} from "@/lib/zod"; +import {withUpdatedAt} from "@/db/utils"; +import {userAction} from "@/lib/safe-actions/actions"; +import { + addMemberOrganizationAction +} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action"; +import {sendEmail} from "@/lib/email/email-helper"; +import EmailCreateUser from "@/components/emails/email-create-user"; +import {SignUpUser} from "@/types/auth"; +import {createUserDb} from "@/db/services/user"; +import {User} from "@/db/schema/02_user"; + +export const createUserAction = userAction.schema(UserSchema).action(async ({parsedInput}): Promise> => { + try { + const password = generateValidPassword(); + + const userData: SignUpUser = { + name: parsedInput.name || extractNameFromEmail(parsedInput.email), + email: parsedInput.email, + password: password, + theme: "dark", + role: "user", + }; + + const newUser = await createUserDb(userData); + + if (newUser) { + + await sendEmail({ + to: parsedInput.email, + subject: "Your account is created", + html: await render(EmailCreateUser({ + password: password, + email: parsedInput.email, + })), + }); + + const defaultOrganization = await db.query.organization.findFirst({ + where: eq(drizzleDb.schemas.organization.slug, "default"), + }); + + if (defaultOrganization) { + await auth.api.addMember({ + body: { + userId: newUser.id, + organizationId: defaultOrganization.id, + role: "admin", + }, + }); + } + + return { + success: true, + value: newUser, + actionSuccess: { + message: "user_created", + }, + }; + } + return { + success: false, + actionError: { + message: "user_created", + cause: "Unknown error", + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "user_created", + cause: error instanceof Error ? error.message : "Unknown error", + }, + }; + } +}); + +export const updateUserAction = userAction + .schema( + z.object({ + id: zString(), + name: zString().optional(), + email: zEmail(), + }) + ) + .action(async ({parsedInput, ctx}): Promise> => { + try { + + + const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({ + name: parsedInput.name ? parsedInput.name : extractNameFromEmail(parsedInput.email), + email: parsedInput.email, + emailVerified: false + })).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning(); + + + if (updatedUser) { + return { + success: true, + actionSuccess: { + message: "user_updated", + }, + }; + } + + return { + success: false, + actionError: { + message: "user_updated", + cause: "Unknown error", + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "user_updated", + cause: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + }); + +export const setSuperAdminOwnerOfOrganizationsOwnedByUser = userAction + .schema( + z.object({ + userId: z.string(), + }) + ) + .action(async ({parsedInput}): Promise> => { + try { + const organizationsWhereUserIsMemberAndOwner = await db.query.member.findMany({ + where: and(eq(drizzleDb.schemas.member.role, "owner"), eq(drizzleDb.schemas.member.userId, parsedInput.userId)), + with: { + organization: true, + }, + }); + + const superAdminUser = await db.query.user.findFirst(); + if (!superAdminUser) { + return { + success: false, + actionError: { + message: "set_super_admin_owner_of_organizations_owned_by_user", + cause: "Unknown error", + }, + }; + } + + for (let {organization} of organizationsWhereUserIsMemberAndOwner) { + await addMemberOrganizationAction({ + userId: superAdminUser.id, + organizationId: organization.id, + role: "owner", + }); + } + const organizations = organizationsWhereUserIsMemberAndOwner.map( + (organizationWhereUserIsMemberAndOwner) => organizationWhereUserIsMemberAndOwner.organization + ); + + return { + success: true, + value: organizations as unknown as Organization[], + actionSuccess: { + message: "set_super_admin_owner_of_organizations_owned_by_user", + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "error_set_super_admin_owner_of_organizations_owned_by_user", + cause: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + }); diff --git a/src/components/wrappers/dashboard/admin/users/user.schema.ts b/src/components/wrappers/dashboard/admin/users/user.schema.ts new file mode 100644 index 00000000..6850137e --- /dev/null +++ b/src/components/wrappers/dashboard/admin/users/user.schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { zEmail, zString } from "@/lib/zod"; + +export const UserSchema = z.object({ + email: zEmail(), + name: zString(), +}); + +export type UserType = z.infer; + +export const UserEditSchema = z.object({ + email: zEmail(), + name: zString(), +}); + +export type UserEditType = z.infer; diff --git a/src/components/wrappers/dashboard/admin/users/accounts/table-columns.tsx b/src/components/wrappers/dashboard/admin/users2/accounts/table-columns.tsx similarity index 100% rename from src/components/wrappers/dashboard/admin/users/accounts/table-columns.tsx rename to src/components/wrappers/dashboard/admin/users2/accounts/table-columns.tsx diff --git a/src/components/wrappers/dashboard/admin/users/admin-user-table.tsx b/src/components/wrappers/dashboard/admin/users2/admin-user-table.tsx similarity index 100% rename from src/components/wrappers/dashboard/admin/users/admin-user-table.tsx rename to src/components/wrappers/dashboard/admin/users2/admin-user-table.tsx diff --git a/src/components/wrappers/dashboard/admin/users/button-delete-use.tsx b/src/components/wrappers/dashboard/admin/users2/button-delete-use.tsx similarity index 100% rename from src/components/wrappers/dashboard/admin/users/button-delete-use.tsx rename to src/components/wrappers/dashboard/admin/users2/button-delete-use.tsx diff --git a/src/components/wrappers/dashboard/admin/users/columns-users.tsx b/src/components/wrappers/dashboard/admin/users2/columns-users.tsx similarity index 100% rename from src/components/wrappers/dashboard/admin/users/columns-users.tsx rename to src/components/wrappers/dashboard/admin/users2/columns-users.tsx diff --git a/src/components/wrappers/dashboard/admin/users/sessions/table-columns.tsx b/src/components/wrappers/dashboard/admin/users2/sessions/table-columns.tsx similarity index 100% rename from src/components/wrappers/dashboard/admin/users/sessions/table-columns.tsx rename to src/components/wrappers/dashboard/admin/users2/sessions/table-columns.tsx diff --git a/src/components/wrappers/dashboard/profile/components/avatar-with-upload.tsx b/src/components/wrappers/dashboard/profile/components/avatar-with-upload.tsx index 8cc64eef..164b572c 100644 --- a/src/components/wrappers/dashboard/profile/components/avatar-with-upload.tsx +++ b/src/components/wrappers/dashboard/profile/components/avatar-with-upload.tsx @@ -4,7 +4,7 @@ import { UploadIcon } from "lucide-react"; import { toast } from "sonner"; import { uploadImageAction } from "@/features/upload/public/upload.action"; import { useMutation } from "@tanstack/react-query"; -import { updateImageUserAction } from "@/components/wrappers/dashboard/profile2/avatar/avatar.action"; +import { updateImageUserAction } from "@/components/wrappers/dashboard/profile/actions/avatar.action"; import { useRouter } from "next/navigation"; import { User } from "@/db/schema/02_user"; import React, {ChangeEvent} from "react"; diff --git a/src/components/wrappers/dashboard/profile2/avatar/avatar-with-upload.tsx b/src/components/wrappers/dashboard/profile2/avatar/avatar-with-upload.tsx deleted file mode 100644 index 2acf76b6..00000000 --- a/src/components/wrappers/dashboard/profile2/avatar/avatar-with-upload.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { UploadIcon } from "lucide-react"; -import { toast } from "sonner"; -import { uploadImageAction } from "@/features/upload/public/upload.action"; -import { useMutation } from "@tanstack/react-query"; -import { updateImageUserAction } from "@/components/wrappers/dashboard/profile2/avatar/avatar.action"; -import { useRouter } from "next/navigation"; -import { User } from "@/db/schema/02_user"; -import {ChangeEvent} from "react"; - -export type AvatarWithUploadProps = { - user: User; -}; - -export const AvatarWithUpload = (props: AvatarWithUploadProps) => { - const user = props.user; - const router = useRouter(); - - const submitImage = useMutation({ - mutationFn: async (file: File) => { - const formData = new FormData(); - formData.set("file", file); - const uploadImage = await uploadImageAction(formData); - const data = uploadImage?.data?.data; - - if (uploadImage?.serverError || !data) { - console.log(uploadImage?.serverError); - toast.error(uploadImage?.serverError); - return; - } - - const updateUser = await updateImageUserAction(data.url); - const dataUser = updateUser?.data?.data; - - if (updateUser?.serverError || !dataUser) { - console.log(updateUser?.serverError); - toast.error(updateUser?.serverError); - return; - } - - toast.success("Successfully uploaded user image!"); - router.refresh(); - }, - }); - - const handleImageUpload = async (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - if (!file.type.includes("image")) { - toast.error("File not an image"); - return; - } - submitImage.mutate(file); - }; - - - - return ( -
- - {user.name[0]} - {user.image ? : null} - -
{ - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = "image/*"; - // @ts-ignore - fileInput.onchange = handleImageUpload; - fileInput.click(); - }} - className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-100 hover:bg-gray-500 hover:bg-opacity-50 rounded-full size-14" - > - -
-
- ); -}; diff --git a/src/components/wrappers/dashboard/profile2/avatar/avatar.action.ts b/src/components/wrappers/dashboard/profile2/avatar/avatar.action.ts deleted file mode 100644 index d44cc2a2..00000000 --- a/src/components/wrappers/dashboard/profile2/avatar/avatar.action.ts +++ /dev/null @@ -1,15 +0,0 @@ -"use server"; -import { db } from "@/db"; -import {userAction} from "@/lib/safe-actions/actions"; -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import * as drizzleDb from "@/db"; - - -export const updateImageUserAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }) => { - const [updatedUser] = await db.update(drizzleDb.schemas.user).set({ image: parsedInput }).where(eq(drizzleDb.schemas.user.id, ctx.user.id)).returning(); - - return { - data: updatedUser, - }; -}); diff --git a/src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx b/src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx index ad96f327..852ba1a9 100644 --- a/src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx +++ b/src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx @@ -58,4 +58,4 @@ export const ButtonDeleteAccount = (props: ButtonDeleteAccountProps) => { ); -}; +}; \ No newline at end of file diff --git a/src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts b/src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts index 9da0376b..a9214f0f 100644 --- a/src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts +++ b/src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts @@ -33,4 +33,4 @@ export const deleteUserAction = userAction.schema(z.string()).action(async ({ pa return { data: deletedUser, }; -}); +}); \ No newline at end of file diff --git a/src/components/wrappers/dashboard/profile2/user-avatar/user-avatar.tsx b/src/components/wrappers/dashboard/profile2/user-avatar/user-avatar.tsx deleted file mode 100644 index 6e26d4f6..00000000 --- a/src/components/wrappers/dashboard/profile2/user-avatar/user-avatar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { currentUser } from "@/lib/auth/current-user"; -import { notFound } from "next/navigation"; - -export type UserAvatarProps = {}; - -export const UserAvatar = async () => { - const user = await currentUser(); - - if (!user) { - return notFound(); - } - - return ( -
- - {user.name[0]} - {user.image ? : null} - -
- ); -}; diff --git a/src/components/wrappers/dashboard/profile2/user-form/user-form.tsx b/src/components/wrappers/dashboard/profile2/user-form/user-form.tsx deleted file mode 100644 index 9b09a038..00000000 --- a/src/components/wrappers/dashboard/profile2/user-form/user-form.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Form } from "@/components/ui/form"; -import { Button } from "@/components/ui/button"; -import { useRouter } from "next/navigation"; -import { useMutation } from "@tanstack/react-query"; -import { TooltipProvider } from "@/components/ui/tooltip"; -import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile2/user-form/user-form.schema"; -import { toast } from "sonner"; -import { updateUserAction } from "@/components/wrappers/dashboard/profile2/user-form/user-form.action"; -import {DataTable} from "@/components/wrappers/common/table/data-table"; -import {sessionsColumns} from "@/components/wrappers/dashboard/admin/users/sessions/table-columns"; -import {accountsColumns} from "@/components/wrappers/dashboard/admin/users/accounts/table-columns"; -import {Account, Session} from "better-auth"; - -export type UserFormProps = { - defaultValues?: UserType; - userId?: string; - sessions: Session[]; - accounts?: Account[]; -}; - -export const UserForm = (props: UserFormProps) => { - const isCreate = !Boolean(props.defaultValues); - - const form = useZodForm({ - schema: UserSchema, - defaultValues: props.defaultValues, - }); - - const router = useRouter(); - - const mutation = useMutation({ - mutationFn: async (values: UserType) => { - const updateUser = await updateUserAction({ - id: props.userId ?? "-", - data: values, - }); - - const data = updateUser?.data?.data; - if (updateUser?.serverError || !data) { - toast.error(updateUser?.serverError); - return; - } - - toast.success(`Profile updated successfully.`); - router.refresh(); - router.push(`/dashboard/profile`); - }, - }); - - return ( -
- - - - Account - Your informations - - -
{ - await mutation.mutateAsync(values); - }} - > - ( - - Name - - - - - - )} - /> - ( - - Email - - - - - - )} - /> - - -
-
- - - Active sessions - Manage your active sessions - - - - - - - - Auth providers - Manage your active auth providers - - - - - -
-
- ); -}; diff --git a/src/db/schema/02_user.ts b/src/db/schema/02_user.ts index 378d17f9..94ca7272 100644 --- a/src/db/schema/02_user.ts +++ b/src/db/schema/02_user.ts @@ -126,6 +126,10 @@ export const projectRelations = relations(project, ({one}) => ({ export const userSchema = createSelectSchema(user); export type User = z.infer; +export const userThemeEnumSchema = createSelectSchema(userThemeEnum) +export type UserThemeEnum = z.infer; + + export const sessionSchema = createSelectSchema(session); export type Session = z.infer; diff --git a/src/db/services/user.ts b/src/db/services/user.ts new file mode 100644 index 00000000..5e829707 --- /dev/null +++ b/src/db/services/user.ts @@ -0,0 +1,34 @@ +import {SignUpUser} from "@/types/auth"; +import {hashPassword} from "better-auth/crypto"; +import {db} from "@/db"; +import * as drizzleDb from "@/db"; +import {User, UserThemeEnum} from "@/db/schema/02_user"; + + +export async function createUserDb(data: SignUpUser): Promise { + const now = new Date(); + const hashedPassword = await hashPassword(data.password); + const userId = crypto.randomUUID(); + + const [newUser] = await db.insert(drizzleDb.schemas.user).values({ + id: userId, + name: data.name, + email: data.email, + emailVerified: true, + role: data.role, + createdAt: now, + updatedAt: now, + theme: data.theme as UserThemeEnum, + }).returning(); + + await db.insert(drizzleDb.schemas.account).values({ + providerId: "credential", + accountId: userId, + userId: userId, + password: hashedPassword, + createdAt: now, + updatedAt: now, + }); + + return newUser +} diff --git a/src/types/auth.ts b/src/types/auth.ts index bc6ea81c..633bc266 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -4,8 +4,8 @@ export type SignUpUser = { email: string password: string callbackURL?: string + role?: string theme: string - isDefaultPassword: boolean } diff --git a/src/utils/name-from-email.ts b/src/utils/name-from-email.ts new file mode 100644 index 00000000..7e65e8bb --- /dev/null +++ b/src/utils/name-from-email.ts @@ -0,0 +1,11 @@ +export function extractNameFromEmail(email: string): string { + const localPart = email.split("@")[0]; + const nameParts = localPart + .replace(/[_\.\-]/g, " ") // Replace underscores, dots, and hyphens with spaces + .split(" ") // Split into parts + .filter(Boolean); // Remove empty strings + + return nameParts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) // Capitalize each part + .join(" "); +} diff --git a/src/utils/password.ts b/src/utils/password.ts new file mode 100644 index 00000000..53b07108 --- /dev/null +++ b/src/utils/password.ts @@ -0,0 +1,27 @@ +export function generateValidPassword(length = 12) { + const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const lower = 'abcdefghijklmnopqrstuvwxyz'; + const numbers = '0123456789'; + const special = '#?!@$%^&*-'; + const all = upper + lower + numbers + special; + + const getRandomChar = (set: string) => set[Math.floor(Math.random() * set.length)]; + + const passwordChars = [ + getRandomChar(upper), + getRandomChar(lower), + getRandomChar(numbers), + getRandomChar(special), + ]; + + for (let i = passwordChars.length; i < length; i++) { + passwordChars.push(getRandomChar(all)); + } + + for (let i = passwordChars.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [passwordChars[i], passwordChars[j]] = [passwordChars[j], passwordChars[i]]; + } + + return passwordChars.join(''); +} \ No newline at end of file