diff --git a/packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx b/packages/admin-next/dashboard/src/modules/user/user-edit/components/edit-user-form/edit-user-form.tsx similarity index 100% rename from packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx rename to packages/admin-next/dashboard/src/modules/user/user-edit/components/edit-user-form/edit-user-form.tsx diff --git a/packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/index.ts b/packages/admin-next/dashboard/src/modules/user/user-edit/components/edit-user-form/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/index.ts rename to packages/admin-next/dashboard/src/modules/user/user-edit/components/edit-user-form/index.ts diff --git a/packages/admin-next/dashboard/src/routes/users/user-edit/user-edit.tsx b/packages/admin-next/dashboard/src/modules/user/user-edit/index.tsx similarity index 100% rename from packages/admin-next/dashboard/src/routes/users/user-edit/user-edit.tsx rename to packages/admin-next/dashboard/src/modules/user/user-edit/index.tsx diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx index 2e8ecac565..8eb2231dc9 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -7,6 +7,7 @@ import { Outlet } from "react-router-dom" import { Spinner } from "@medusajs/icons" import { ErrorBoundary } from "../../components/error/error-boundary" import { useV2Session } from "../../lib/api-v2" +import { UserDTO } from "@medusajs/types" import { SearchProvider } from "../search-provider" import { SidebarProvider } from "../sidebar-provider" @@ -139,6 +140,38 @@ export const v2Routes: RouteObject[] = [ }, ], }, + { + path: "users", + element: , + handle: { + crumb: () => "Users", + }, + children: [ + { + path: "", + lazy: () => import("../../v2-routes/users/user-list"), + children: [ + { + path: "invite", + lazy: () => import("../../v2-routes/users/user-invite"), + }, + ], + }, + { + path: ":id", + lazy: () => import("../../v2-routes/users/user-detail"), + handle: { + crumb: (data: { user: UserDTO }) => data.user.email, + }, + children: [ + { + path: "edit", + lazy: () => import("../../v2-routes/users/user-edit"), + }, + ], + }, + ], + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/users/user-edit/index.ts b/packages/admin-next/dashboard/src/routes/users/user-edit/index.ts index ca520c7d9e..50a4319a8d 100644 --- a/packages/admin-next/dashboard/src/routes/users/user-edit/index.ts +++ b/packages/admin-next/dashboard/src/routes/users/user-edit/index.ts @@ -1 +1 @@ -export { UserEdit as Component } from "./user-edit" +export { UserEdit as Component } from "../../../modules/user/user-edit" diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-detail/components/user-general-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/components/user-general-section/index.ts new file mode 100644 index 0000000000..4712aa6657 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/components/user-general-section/index.ts @@ -0,0 +1 @@ +export * from "./user-general-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-detail/components/user-general-section/user-general-section.tsx b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/components/user-general-section/user-general-section.tsx new file mode 100644 index 0000000000..8bb7871bf8 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/components/user-general-section/user-general-section.tsx @@ -0,0 +1,88 @@ +import { PencilSquare, Trash } from "@medusajs/icons" +import { User } from "@medusajs/medusa" +import { Container, Heading, Text, clx, usePrompt } from "@medusajs/ui" +import { useAdminDeleteUser } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" +import { ActionMenu } from "../../../../../components/common/action-menu" + +type UserGeneralSectionProps = { + user: Omit +} + +export const UserGeneralSection = ({ user }: UserGeneralSectionProps) => { + const { t } = useTranslation() + const navigate = useNavigate() + const prompt = usePrompt() + + const { mutateAsync } = useAdminDeleteUser(user.id) + + const name = [user.first_name, user.last_name].filter(Boolean).join(" ") + + const handleDeleteUser = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("users.deleteUserWarning", { + name: name ?? user.email, + }), + verificationText: name ?? user.email, + verificationInstruction: t("general.typeToConfirm"), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync(undefined, { + onSuccess: () => { + navigate("..") + }, + }) + } + + return ( + +
+ {user.email} + , + }, + ], + }, + { + actions: [ + { + label: t("actions.delete"), + onClick: handleDeleteUser, + icon: , + }, + ], + }, + ]} + /> +
+
+ + {t("fields.name")} + + + {name ?? "-"} + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/index.ts new file mode 100644 index 0000000000..d91450c4f0 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/index.ts @@ -0,0 +1,2 @@ +export { userLoader as loader } from "./loader" +export { UserDetail as Component } from "./user-detail" diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-detail/loader.ts b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/loader.ts new file mode 100644 index 0000000000..1dbeb58ba8 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/loader.ts @@ -0,0 +1,21 @@ +import { AdminUserRes } from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { adminProductKeys } from "medusa-react" +import { LoaderFunctionArgs } from "react-router-dom" + +import { medusa, queryClient } from "../../../lib/medusa" + +const userDetailQuery = (id: string) => ({ + queryKey: adminProductKeys.detail(id), + queryFn: async () => medusa.admin.users.retrieve(id), +}) + +export const userLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = userDetailQuery(id!) + + return ( + queryClient.getQueryData>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-detail/user-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/user-detail.tsx new file mode 100644 index 0000000000..460d6ba81f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-detail/user-detail.tsx @@ -0,0 +1,34 @@ +import { useAdminUser } from "medusa-react" +import { Outlet, json, useLoaderData, useParams } from "react-router-dom" +import { JsonViewSection } from "../../../components/common/json-view-section" +import { UserGeneralSection } from "./components/user-general-section" +import { userLoader } from "./loader" + +export const UserDetail = () => { + const initialData = useLoaderData() as Awaited> + + const { id } = useParams() + const { user, isLoading, isError, error } = useAdminUser(id!, { + initialData, + }) + + if (isLoading) { + return
Loading...
+ } + + if (isError || !user) { + if (error) { + throw error + } + + throw json("An unknown error has occured", 500) + } + + return ( +
+ + + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-edit/index.ts b/packages/admin-next/dashboard/src/v2-routes/users/user-edit/index.ts new file mode 100644 index 0000000000..50a4319a8d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-edit/index.ts @@ -0,0 +1 @@ +export { UserEdit as Component } from "../../../modules/user/user-edit" diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-invite/components/invite-user-form/invite-user-form.tsx b/packages/admin-next/dashboard/src/v2-routes/users/user-invite/components/invite-user-form/invite-user-form.tsx new file mode 100644 index 0000000000..504868ab4b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-invite/components/invite-user-form/invite-user-form.tsx @@ -0,0 +1,381 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { ArrowPath, Link, XCircle } from "@medusajs/icons" +import { Invite } from "@medusajs/medusa" +import { + Button, + Container, + Heading, + Input, + StatusBadge, + Table, + Text, + Tooltip, + clx, + usePrompt, +} from "@medusajs/ui" +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table" +import { format } from "date-fns" +import { + adminInviteKeys, + useAdminCustomPost, + useAdminDeleteInvite, + useAdminInvites, + useAdminResendInvite, +} from "medusa-react" +import { useMemo } from "react" +import { useForm } from "react-hook-form" +import { Trans, useTranslation } from "react-i18next" +import * as zod from "zod" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { NoRecords } from "../../../../../components/common/empty-table-content" +import { Form } from "../../../../../components/common/form" +import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination" +import { RouteFocusModal } from "../../../../../components/route-modal" +import { useV2Store } from "../../../../../lib/api-v2" +import { InviteDTO } from "@medusajs/types" + +const InviteUserSchema = zod.object({ + email: zod.string().email(), +}) + +const PAGE_SIZE = 10 + +export const InviteUserForm = () => { + const { t } = useTranslation() + + const form = useForm>({ + defaultValues: { + email: "", + }, + resolver: zodResolver(InviteUserSchema), + }) + + const { invites, isLoading, isError, error } = useAdminInvites() + const count = invites?.length ?? 0 + + const noRecords = !isLoading && count === 0 + + const columns = useColumns() + + const table = useReactTable({ + // TODO: Update type when medusa-react is 2.0 compatible + data: (invites ?? []) as unknown as InviteDTO[], + columns, + pageCount: Math.ceil(count / PAGE_SIZE), + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + const { mutateAsync, isLoading: isMutating } = useAdminCustomPost( + "/admin/invites", + adminInviteKeys.lists() + ) + + const handleSubmit = form.handleSubmit(async (values) => { + await mutateAsync( + { + email: values.email, + }, + { + onSuccess: () => { + form.reset() + }, + } + ) + }) + + if (isError) { + throw error + } + + return ( + +
+ + +
+
+
+ {t("users.inviteUser")} + + {t("users.inviteUserHint")} + +
+
+
+ { + return ( + + {t("fields.email")} + + + + + + ) + }} + /> +
+
+ +
+
+
+ {t("users.pendingInvites")} + + {!noRecords ? ( +
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+ +
+ ) : ( + + )} +
+
+
+
+
+ +
+ ) +} + +const InviteActions = ({ invite }: { invite: InviteDTO }) => { + const { mutateAsync: revokeAsync } = useAdminDeleteInvite(invite.id) + const { mutateAsync: resendAsync } = useAdminResendInvite(invite.id) + const { store, isLoading, isError, error } = useV2Store({}) + const prompt = usePrompt() + const { t } = useTranslation() + + const handleRevoke = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("users.revokeInviteWarning", { + email: invite.email, + }), + cancelText: t("actions.cancel"), + confirmText: t("actions.revoke"), + }) + + if (!res) { + return + } + + await revokeAsync() + } + + const handleResend = async () => { + await resendAsync() + } + + const handleCopyInviteLink = () => { + const template = store?.invite_link_template + + if (!template) { + return + } + + const link = template.replace("{invite_token}", invite.token) + navigator.clipboard.writeText(link) + } + + if (isError) { + throw error + } + + return ( + , + label: t("users.copyInviteLink"), + disabled: isLoading || !store?.invite_link_template, + onClick: handleCopyInviteLink, + }, + { + icon: , + label: t("users.resendInvite"), + onClick: handleResend, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.revoke"), + onClick: handleRevoke, + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("email", { + header: t("fields.email"), + cell: ({ getValue }) => { + return getValue() + }, + }), + columnHelper.accessor("accepted", { + header: t("fields.status"), + cell: ({ getValue, row }) => { + const accepted = getValue() + const expired = new Date(row.original.expires_at) < new Date() + + if (accepted) { + return ( + + + {t("users.inviteStatus.accepted")} + + + ) + } + + if (expired) { + return ( + + + {t("users.inviteStatus.expired")} + + + ) + } + + return ( + , + , + ]} + values={{ + from: format( + new Date(row.original.created_at), + "dd MMM, yyyy" + ), + until: format( + new Date(row.original.expires_at), + "dd MMM, yyyy" + ), + }} + /> + } + > + + {t("users.inviteStatus.pending")} + + + ) + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => , + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-invite/index.ts b/packages/admin-next/dashboard/src/v2-routes/users/user-invite/index.ts new file mode 100644 index 0000000000..882f7aca37 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-invite/index.ts @@ -0,0 +1 @@ +export { UserInvite as Component } from "./user-invite" diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-invite/user-invite.tsx b/packages/admin-next/dashboard/src/v2-routes/users/user-invite/user-invite.tsx new file mode 100644 index 0000000000..23432b8d22 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-invite/user-invite.tsx @@ -0,0 +1,10 @@ +import { RouteFocusModal } from "../../../components/route-modal" +import { InviteUserForm } from "./components/invite-user-form/invite-user-form" + +export const UserInvite = () => { + return ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-list/components/user-list-table/index.ts b/packages/admin-next/dashboard/src/v2-routes/users/user-list/components/user-list-table/index.ts new file mode 100644 index 0000000000..13c4e5e680 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-list/components/user-list-table/index.ts @@ -0,0 +1 @@ +export * from "./user-list-table" diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-list/components/user-list-table/user-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/users/user-list/components/user-list-table/user-list-table.tsx new file mode 100644 index 0000000000..14d11e3f1c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-list/components/user-list-table/user-list-table.tsx @@ -0,0 +1,232 @@ +import { PencilSquare } from "@medusajs/icons" +import { User } from "@medusajs/medusa" +import { Button, Container, Heading, Table, clx } from "@medusajs/ui" +import { + PaginationState, + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { useAdminUsers } from "medusa-react" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Link, useNavigate } from "react-router-dom" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { + NoRecords, + NoResults, +} from "../../../../../components/common/empty-table-content" +import { OrderBy } from "../../../../../components/filtering/order-by" +import { Query } from "../../../../../components/filtering/query" +import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination" +import { useQueryParams } from "../../../../../hooks/use-query-params" + +const PAGE_SIZE = 50 + +export const UserListTable = () => { + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ) + + const params = useQueryParams(["q", "order"]) + const { users, count, isLoading, isError, error } = useAdminUsers( + { + limit: PAGE_SIZE, + offset: pageIndex * PAGE_SIZE, + ...params, + }, + { + keepPreviousData: true, + } + ) + + const columns = useColumns() + + const table = useReactTable({ + data: users ?? [], + columns, + pageCount: Math.ceil((count ?? 0) / PAGE_SIZE), + state: { + pagination, + }, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }) + + const { t } = useTranslation() + const navigate = useNavigate() + + const noRecords = + !isLoading && + !users?.length && + !Object.values(params).filter(Boolean).length + + if (isError) { + throw error + } + + return ( + +
+ {t("users.domain")} + +
+ {!noRecords && ( +
+
+
+ + +
+
+ )} + {noRecords ? ( + + ) : ( +
+ {!isLoading && !users?.length ? ( +
+ +
+ ) : ( + + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + navigate(row.original.id)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+ )} + +
+ )} +
+ ) +} + +const UserActions = ({ user }: { user: Omit }) => { + const { t } = useTranslation() + + return ( + , + label: t("actions.edit"), + to: `${user.id}/edit`, + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = createColumnHelper>() + +const useColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("email", { + header: t("fields.email"), + cell: ({ row }) => { + return row.original.email + }, + }), + columnHelper.display({ + id: "name", + header: t("fields.name"), + cell: ({ row }) => { + const name = [row.original.first_name, row.original.last_name] + .filter(Boolean) + .join(" ") + + if (!name) { + return - + } + + return name + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => , + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-list/index.ts b/packages/admin-next/dashboard/src/v2-routes/users/user-list/index.ts new file mode 100644 index 0000000000..c6b98dbbbd --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-list/index.ts @@ -0,0 +1 @@ +export { UserList as Component } from "./user-list" diff --git a/packages/admin-next/dashboard/src/v2-routes/users/user-list/user-list.tsx b/packages/admin-next/dashboard/src/v2-routes/users/user-list/user-list.tsx new file mode 100644 index 0000000000..c90a5215fd --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/users/user-list/user-list.tsx @@ -0,0 +1,11 @@ +import { Outlet } from "react-router-dom" +import { UserListTable } from "./components/user-list-table" + +export const UserList = () => { + return ( +
+ + +
+ ) +}