diff --git a/apps/app/src/app/admin/page.tsx b/apps/app/src/app/admin/page.tsx deleted file mode 100644 index ce5dd92..0000000 --- a/apps/app/src/app/admin/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import { ProtectedRoute } from "@/components/auth/protected-route"; - -function AdminPage() { - return ( - -
-

Admin Dashboard

- {/* Your admin content */} -
-
- ); -} - -export default AdminPage; diff --git a/apps/app/src/app/profile/components/password-form.tsx b/apps/app/src/app/profile/components/password-form.tsx index 74edf3b..3dee8dd 100644 --- a/apps/app/src/app/profile/components/password-form.tsx +++ b/apps/app/src/app/profile/components/password-form.tsx @@ -68,7 +68,7 @@ export function PasswordForm({
diff --git a/apps/app/src/app/profile/components/profile-picture.tsx b/apps/app/src/app/profile/components/profile-picture.tsx index 57920f3..9d1e747 100644 --- a/apps/app/src/app/profile/components/profile-picture.tsx +++ b/apps/app/src/app/profile/components/profile-picture.tsx @@ -48,7 +48,7 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi : ""} - + + + + onEdit(user)}> + + {t("users.actions.edit")} + + onToggleStatus(user)}> + {user.isActive ? ( + + ) : ( + + )} + {user.isActive ? t("users.actions.deactivate") : t("users.actions.activate")} + + onDelete(user)} + className="text-destructive" + > + + {t("users.actions.delete")} + + + + ); +} diff --git a/apps/app/src/app/users-management/components/user-delete-modal.tsx b/apps/app/src/app/users-management/components/user-delete-modal.tsx new file mode 100644 index 0000000..06304df --- /dev/null +++ b/apps/app/src/app/users-management/components/user-delete-modal.tsx @@ -0,0 +1,41 @@ +import { UserDeleteModalProps } from "../types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, +} from "@/components/ui/dialog"; +import { useTranslations } from "next-intl"; + +export function UserDeleteModal({ isOpen, onClose, user, onConfirm }: UserDeleteModalProps) { + const t = useTranslations(); + + return ( + + + + {t("users.delete.title")} + +
+ {user && ( +

+ {t("users.delete.confirmation", { + firstName: user.firstName, + lastName: user.lastName, + })} +

+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/app/src/app/users-management/components/user-form-modal.tsx b/apps/app/src/app/users-management/components/user-form-modal.tsx new file mode 100644 index 0000000..10b4e8c --- /dev/null +++ b/apps/app/src/app/users-management/components/user-form-modal.tsx @@ -0,0 +1,142 @@ +import { UserFormModalProps } from "../types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTranslations } from "next-intl"; +import { IconDeviceFloppy } from "@tabler/icons-react"; +import { Label } from "@/components/ui/label"; +import { FormMessage } from "@/components/ui/form"; + +export function UserFormModal({ isOpen, onClose, modalMode, selectedUser, formMethods, onSubmit }: UserFormModalProps) { + const t = useTranslations(); + const { + register, + formState: { errors, isSubmitting }, + } = formMethods; + + return ( + + +
+ + {modalMode === "create" ? t("users.form.titleCreate") : t("users.form.titleEdit")} + +
+
+
+
+ + + {errors.firstName && ( + {errors.firstName.message} + )} +
+
+ + + {errors.lastName && ( + {errors.lastName.message} + )} +
+
+ +
+ + + {errors.username && ( + {errors.username.message} + )} +
+ +
+ + + {errors.email && ( + {errors.email.message} + )} +
+ +
+ + + {errors.password && ( + {errors.password.message} + )} +
+ + {modalMode === "edit" && ( +
+ + + {errors.isAdmin && ( + {errors.isAdmin.message} + )} +
+ )} +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/app/src/app/users-management/components/user-management-modals.tsx b/apps/app/src/app/users-management/components/user-management-modals.tsx new file mode 100644 index 0000000..a00b5aa --- /dev/null +++ b/apps/app/src/app/users-management/components/user-management-modals.tsx @@ -0,0 +1,42 @@ +import { UserManagementModalsProps } from "../types"; +import { UserDeleteModal } from "./user-delete-modal"; +import { UserFormModal } from "./user-form-modal"; +import { UserStatusModal } from "./user-status-modal"; + +export function UserManagementModals({ + modals, + selectedUser, + deleteModalUser, + statusModalUser, + onSubmit, + onDelete, + onToggleStatus, + formMethods, +}: UserManagementModalsProps) { + return ( + <> + + + + + + + ); +} diff --git a/apps/app/src/app/users-management/components/user-status-modal.tsx b/apps/app/src/app/users-management/components/user-status-modal.tsx new file mode 100644 index 0000000..3311920 --- /dev/null +++ b/apps/app/src/app/users-management/components/user-status-modal.tsx @@ -0,0 +1,46 @@ +import { UserStatusModalProps } from "../types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, +} from "@/components/ui/dialog"; +import { useTranslations } from "next-intl"; + +export function UserStatusModal({ isOpen, onClose, user, onConfirm }: UserStatusModalProps) { + const t = useTranslations(); + + return ( + + + + {t("users.status.title")} + +
+ {user && ( +

+ {t("users.status.confirmation", { + action: user.isActive ? t("users.status.deactivate") : t("users.status.activate"), + firstName: user.firstName, + lastName: user.lastName, + })} +

+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/app/src/app/users-management/components/users-header.tsx b/apps/app/src/app/users-management/components/users-header.tsx new file mode 100644 index 0000000..d408d70 --- /dev/null +++ b/apps/app/src/app/users-management/components/users-header.tsx @@ -0,0 +1,54 @@ +import { UsersHeaderProps } from "../types"; +import Link from "next/link"; +import { IconLayoutDashboard, IconUsers, IconUserPlus } from "@tabler/icons-react"; +import { useTranslations } from "next-intl"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; + +export function UsersHeader({ onCreateUser }: UsersHeaderProps) { + const t = useTranslations(); + + return ( +
+
+
+ +

{t("users.header.title")}

+
+ +
+ + + + + + + + {t("common.dashboard")} + + + + + + + {t("users.header.management")} + + + + +
+ ); +} diff --git a/apps/app/src/app/users-management/components/users-table.tsx b/apps/app/src/app/users-management/components/users-table.tsx new file mode 100644 index 0000000..8c470bc --- /dev/null +++ b/apps/app/src/app/users-management/components/users-table.tsx @@ -0,0 +1,84 @@ +import { UsersTableProps } from "../types"; +import { UserActionsDropdown } from "./user-actions-dropdown"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useTranslations } from "next-intl"; + +export function UsersTable({ users, currentUser, onEdit, onDelete, onToggleStatus }: UsersTableProps) { + const t = useTranslations(); + const isCurrentUser = (userId: string) => currentUser?.id === userId; + + return ( +
+ + + + + {t("users.table.user")} + + + {t("users.table.email")} + + + {t("users.table.status")} + + + {t("users.table.role")} + + + {t("users.table.actions")} + + + + + {users.map((user) => ( + + +
+ + + + {user.firstName[0]} + + +
+

{`${user.firstName} ${user.lastName}`}

+

{user.username}

+
+
+
+ {user.email} + + + {user.isActive ? t("users.table.active") : t("users.table.inactive")} + + + + + {user.isAdmin ? t("users.table.admin") : t("users.table.userr")} + + + + + +
+ ))} +
+
+
+ ); +} diff --git a/apps/app/src/app/users-management/hooks/use-user-management.ts b/apps/app/src/app/users-management/hooks/use-user-management.ts new file mode 100644 index 0000000..b18ee64 --- /dev/null +++ b/apps/app/src/app/users-management/hooks/use-user-management.ts @@ -0,0 +1,180 @@ +"use client" + +import { useAuth } from "@/contexts/auth-context"; +import { useDisclosure } from "@/hooks/use-disclosure"; +import { listUsers, registerUser, updateUser, deleteUser, activateUser, deactivateUser } from "@/http/endpoints"; +import type { ListUsers200Item } from "@/http/models"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { Resolver, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const createSchemas = (t: (key: string) => string) => ({ + userSchema: z.object({ + firstName: z.string().min(1, t("validation.firstNameRequired")), + lastName: z.string().min(1, t("validation.lastNameRequired")), + username: z + .string() + .min(3, t("validation.usernameLength")) + .regex(/^[^\s]+$/, t("validation.usernameSpaces")), + email: z.string().email(t("validation.invalidEmail")), + password: z.string().min(8, t("validation.passwordLength")).or(z.literal("")), + isAdmin: z + .union([z.enum(["true", "false"]), z.boolean()]) + .transform((val) => (typeof val === "string" ? val === "true" : val)) + .optional(), + }), +}); + +export type UserFormData = z.infer["userSchema"]>; + +export function useUserManagement() { + const t = useTranslations(); + + const { userSchema } = createSchemas(t); + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedUser, setSelectedUser] = useState(null); + const [modalMode, setModalMode] = useState<"create" | "edit">("create"); + const [deleteModalUser, setDeleteModalUser] = useState(null); + const [statusModalUser, setStatusModalUser] = useState(null); + + const { user: currentUser } = useAuth(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const { isOpen: isDeleteModalOpen, onOpen: onDeleteModalOpen, onClose: onDeleteModalClose } = useDisclosure(); + const { isOpen: isStatusModalOpen, onOpen: onStatusModalOpen, onClose: onStatusModalClose } = useDisclosure(); + + const formMethods = useForm({ + resolver: zodResolver(userSchema) as Resolver, + }); + + const loadUsers = async () => { + try { + const response = await listUsers(); + + setUsers(response.data); + } catch (error) { + toast.error(t("users.errors.loadFailed")); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadUsers(); + }, []); + + const handleCreateUser = () => { + setModalMode("create"); + setSelectedUser(null); + formMethods.reset({}); + onOpen(); + }; + + const handleEditUser = (user: ListUsers200Item) => { + setModalMode("edit"); + setSelectedUser(user); + formMethods.reset({ + firstName: user.firstName, + lastName: user.lastName, + username: user.username, + email: user.email, + isAdmin: user.isAdmin, + password: "", + }); + onOpen(); + }; + + const onSubmit = async (data: UserFormData) => { + try { + if (modalMode === "create") { + await registerUser(data); + toast.success(t("users.messages.createSuccess")); + } else { + if (!selectedUser) return; + const updateData = { + ...data, + id: selectedUser.id, + } as { id: string } & Partial; + + if (!data.password || data.password.trim() === "") { + delete updateData.password; + } + + await updateUser(updateData); + toast.success(t("users.messages.updateSuccess")); + } + onClose(); + loadUsers(); + } catch (error) { + toast.error(t("users.errors.submitFailed", { mode: t(`users.modes.${modalMode}`) })); + console.error(error); + } + }; + + const handleDeleteUser = async () => { + if (!deleteModalUser) return; + + try { + await deleteUser(deleteModalUser.id); + toast.success(t("users.messages.deleteSuccess")); + loadUsers(); + onDeleteModalClose(); + } catch (error) { + toast.error(t("users.errors.deleteFailed")); + console.error(error); + } + }; + + const handleToggleUserStatus = async () => { + if (!statusModalUser) return; + + try { + if (statusModalUser.isActive) { + await deactivateUser(statusModalUser.id); + toast.success(t("users.messages.deactivateSuccess")); + } else { + await activateUser(statusModalUser.id); + toast.success(t("users.messages.activateSuccess")); + } + loadUsers(); + onStatusModalClose(); + } catch (error) { + toast.error(t("users.errors.statusUpdateFailed")); + console.error(error); + } + }; + + return { + users, + isLoading, + currentUser, + selectedUser, + deleteModalUser, + statusModalUser, + modals: { + isOpen, + onOpen, + onClose, + modalMode, + isDeleteModalOpen, + onDeleteModalOpen, + onDeleteModalClose, + isStatusModalOpen, + onStatusModalOpen, + onStatusModalClose, + setDeleteModalUser, + setStatusModalUser, + }, + handleCreateUser, + handleEditUser, + handleDeleteUser, + handleToggleUserStatus, + onSubmit, + formMethods, + }; +} diff --git a/apps/app/src/app/users-management/layout.tsx b/apps/app/src/app/users-management/layout.tsx new file mode 100644 index 0000000..f41f0be --- /dev/null +++ b/apps/app/src/app/users-management/layout.tsx @@ -0,0 +1,18 @@ +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +interface LayoutProps { + children: React.ReactNode; +} + +export async function generateMetadata(): Promise { + const translate = await getTranslations(); + + return { + title: translate("settings.pageTitle"), + }; +} + +export default function UsersManagementLayout({ children }: LayoutProps) { + return <>{children}; +} diff --git a/apps/app/src/app/users-management/page.tsx b/apps/app/src/app/users-management/page.tsx new file mode 100644 index 0000000..0834c38 --- /dev/null +++ b/apps/app/src/app/users-management/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { ProtectedRoute } from "@/components/auth/protected-route"; +import { UserManagementModals } from "./components/user-management-modals"; +import { UsersHeader } from "./components/users-header"; +import { UsersTable } from "./components/users-table"; +import { useUserManagement } from "./hooks/use-user-management"; +import { LoadingScreen } from "@/components/layout/loading-screen"; +import { Navbar } from "@/components/layout/navbar"; +import { DefaultFooter } from "@/components/ui/default-footer"; + +export default function AdminAreaPage() { + const { + users, + isLoading, + currentUser, + modals, + selectedUser, + deleteModalUser, + statusModalUser, + handleCreateUser, + handleEditUser, + handleDeleteUser, + handleToggleUserStatus, + onSubmit, + formMethods, + } = useUserManagement(); + + if (isLoading) { + return ; + } + + return ( + + +
+ +
+
+ + + { + modals.setDeleteModalUser(user); + modals.onDeleteModalOpen(); + }} + onEdit={handleEditUser} + onToggleStatus={(user) => { + modals.setStatusModalUser(user); + modals.onStatusModalOpen(); + }} + /> +
+
+ + + +
+
+ ); +} diff --git a/apps/app/src/app/users-management/types/index.ts b/apps/app/src/app/users-management/types/index.ts new file mode 100644 index 0000000..05cc83c --- /dev/null +++ b/apps/app/src/app/users-management/types/index.ts @@ -0,0 +1,74 @@ +import { UserFormData } from "../hooks/use-user-management"; +import type { ListUsers200Item } from "@/http/models"; +import { UseFormReturn } from "react-hook-form"; + +export interface UserActionsDropdownProps { + user: ListUsers200Item; + isCurrentUser: boolean; + onEdit: (user: ListUsers200Item) => void; + onDelete: (user: ListUsers200Item) => void; + onToggleStatus: (user: ListUsers200Item) => void; +} + +export interface UserDeleteModalProps { + isOpen: boolean; + onClose: () => void; + user: ListUsers200Item | null; + onConfirm: () => Promise; +} + +export interface UserFormModalProps { + isOpen: boolean; + onClose: () => void; + modalMode: "create" | "edit"; + selectedUser: ListUsers200Item | null; + formMethods: UseFormReturn; + onSubmit: (data: UserFormData) => Promise; +} + +export interface UserManagementModalsProps { + modals: { + isOpen: boolean; + onClose: () => void; + modalMode: "create" | "edit"; + isDeleteModalOpen: boolean; + onDeleteModalClose: () => void; + isStatusModalOpen: boolean; + onStatusModalClose: () => void; + }; + selectedUser: ListUsers200Item | null; + deleteModalUser: ListUsers200Item | null; + statusModalUser: ListUsers200Item | null; + onSubmit: (data: UserFormData) => Promise; + onDelete: () => Promise; + onToggleStatus: () => Promise; + formMethods: UseFormReturn; +} + +export interface UserStatusModalProps { + isOpen: boolean; + onClose: () => void; + user: ListUsers200Item | null; + onConfirm: () => Promise; +} + +export interface UsersHeaderProps { + onCreateUser: () => void; +} + +export interface AuthUser { + id: string; + firstName: string; + lastName: string; + username: string; + email: string; + image: string | null; +} + +export interface UsersTableProps { + users: ListUsers200Item[]; + currentUser: AuthUser | null; + onEdit: (user: ListUsers200Item) => void; + onDelete: (user: ListUsers200Item) => void; + onToggleStatus: (user: ListUsers200Item) => void; +} diff --git a/apps/app/src/components/layout/navbar.tsx b/apps/app/src/components/layout/navbar.tsx index 098977c..c81ad66 100644 --- a/apps/app/src/components/layout/navbar.tsx +++ b/apps/app/src/components/layout/navbar.tsx @@ -77,7 +77,7 @@ export function Navbar() { - + {t("navbar.usersManagement")} diff --git a/apps/app/src/components/ui/dropdown-menu.tsx b/apps/app/src/components/ui/dropdown-menu.tsx index 05c5284..47ac7e4 100644 --- a/apps/app/src/components/ui/dropdown-menu.tsx +++ b/apps/app/src/components/ui/dropdown-menu.tsx @@ -57,7 +57,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "focus:bg-accent focus:text-accent-foreground cursor-pointer data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} diff --git a/apps/app/src/hooks/use-disclosure.ts b/apps/app/src/hooks/use-disclosure.ts new file mode 100644 index 0000000..acfad79 --- /dev/null +++ b/apps/app/src/hooks/use-disclosure.ts @@ -0,0 +1,16 @@ +import { useState } from "react"; + +export function useDisclosure(initial = false) { + const [isOpen, setIsOpen] = useState(initial); + + const onOpen = () => setIsOpen(true); + const onClose = () => setIsOpen(false); + const onToggle = () => setIsOpen(!isOpen); + + return { + isOpen, + onOpen, + onClose, + onToggle, + }; +} \ No newline at end of file