feat(dashboard): Users domain (#6212)
This commit is contained in:
1 parent
7d5a6f8b00
commit
1100c21c63
16 files changed
+831
-85
No files matched your search
@@ -4,9 +4,11 @@
|
|||||||
"ascending": "Ascending",
|
"ascending": "Ascending",
|
||||||
"descending": "Descending",
|
"descending": "Descending",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"close": "Close",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"invite": "Invite",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
"revoke": "Revoke",
|
||||||
"revoked": "Revoked",
|
"revoked": "Revoked",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
@@ -126,7 +129,22 @@
|
|||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"domain": "Users",
|
"domain": "Users",
|
||||||
"role": "Role",
|
"editUser": "Edit User",
|
||||||
|
"inviteUser": "Invite User",
|
||||||
|
"inviteUserHint": "Invite a new user to your store.",
|
||||||
|
"sendInvite": "Send invite",
|
||||||
|
"pendingInvites": "Pending Invites",
|
||||||
|
"revokeInviteWarning": "You are about to revoke the invite for {{email}}. This action cannot be undone.",
|
||||||
|
"resendInvite": "Resend invite",
|
||||||
|
"copyInviteLink": "Copy invite link",
|
||||||
|
"expiredOnDate": "Expired on {{date}}",
|
||||||
|
"validFromUntil": "Valid from <0>{{from}}</0> - <1>{{until}}</1>",
|
||||||
|
"acceptedOnDate": "Accepted on {{date}}",
|
||||||
|
"inviteStatus": {
|
||||||
|
"accepted": "Accepted",
|
||||||
|
"pending": "Pending",
|
||||||
|
"expired": "Expired"
|
||||||
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"developer": "Developer",
|
"developer": "Developer",
|
||||||
@@ -244,6 +262,8 @@
|
|||||||
"account": "Account",
|
"account": "Account",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"key": "Key"
|
"key": "Key",
|
||||||
|
"role": "Role",
|
||||||
|
"sent": "Sent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Link } from "react-router-dom"
|
|||||||
type Action = {
|
type Action = {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
label: string
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
to: string
|
to: string
|
||||||
@@ -47,12 +48,13 @@ export const ActionMenu = ({ groups }: ActionMenuProps) => {
|
|||||||
if (action.onClick) {
|
if (action.onClick) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
|
disabled={action.disabled}
|
||||||
key={index}
|
key={index}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
action.onClick()
|
action.onClick()
|
||||||
}}
|
}}
|
||||||
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2"
|
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
<span>{action.label}</span>
|
<span>{action.label}</span>
|
||||||
@@ -62,12 +64,16 @@ export const ActionMenu = ({ groups }: ActionMenuProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
asChild
|
||||||
|
disabled={action.disabled}
|
||||||
|
>
|
||||||
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
|
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
|
||||||
<DropdownMenu.Item className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2">
|
|
||||||
{action.icon}
|
{action.icon}
|
||||||
<span>{action.label}</span>
|
<span>{action.label}</span>
|
||||||
</DropdownMenu.Item>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</DropdownMenu.Item>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+22
-5
@@ -1,18 +1,24 @@
|
|||||||
import { ExclamationCircle, MagnifyingGlass } from "@medusajs/icons"
|
import { ExclamationCircle, MagnifyingGlass } from "@medusajs/icons"
|
||||||
import { Button, Text } from "@medusajs/ui"
|
import { Button, Text, clx } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
|
|
||||||
type NoResultsProps = {
|
type NoResultsProps = {
|
||||||
title?: string
|
title?: string
|
||||||
message?: string
|
message?: string
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NoResults = ({ title, message }: NoResultsProps) => {
|
export const NoResults = ({ title, message, className }: NoResultsProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[400px] w-full items-center justify-center">
|
<div
|
||||||
|
className={clx(
|
||||||
|
"flex h-[400px] w-full items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center gap-y-2">
|
<div className="flex flex-col items-center gap-y-2">
|
||||||
<MagnifyingGlass />
|
<MagnifyingGlass />
|
||||||
<Text size="small" leading="compact" weight="plus">
|
<Text size="small" leading="compact" weight="plus">
|
||||||
@@ -33,13 +39,24 @@ type NoRecordsProps = {
|
|||||||
to: string
|
to: string
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NoRecords = ({ title, message, action }: NoRecordsProps) => {
|
export const NoRecords = ({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: NoRecordsProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[400px] w-full flex-col items-center justify-center gap-y-6">
|
<div
|
||||||
|
className={clx(
|
||||||
|
"flex h-[400px] w-full flex-col items-center justify-center gap-y-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center gap-y-2">
|
<div className="flex flex-col items-center gap-y-2">
|
||||||
<ExclamationCircle />
|
<ExclamationCircle />
|
||||||
<Text size="small" leading="compact" weight="plus">
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
AdminPublishableApiKeysRes,
|
AdminPublishableApiKeysRes,
|
||||||
AdminRegionsRes,
|
AdminRegionsRes,
|
||||||
AdminSalesChannelsRes,
|
AdminSalesChannelsRes,
|
||||||
|
AdminUserRes,
|
||||||
} from "@medusajs/medusa"
|
} from "@medusajs/medusa"
|
||||||
import {
|
import {
|
||||||
Outlet,
|
Outlet,
|
||||||
@@ -439,6 +440,9 @@ const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: ":id",
|
path: ":id",
|
||||||
lazy: () => import("../../routes/users/user-detail"),
|
lazy: () => import("../../routes/users/user-detail"),
|
||||||
|
handle: {
|
||||||
|
crumb: (data: AdminUserRes) => data.user.email,
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "edit",
|
path: "edit",
|
||||||
|
|||||||
+8
@@ -41,6 +41,14 @@ export const ProfileGeneralSection = ({ user }: ProfileGeneralSectionProps) => {
|
|||||||
{user.email}
|
{user.email}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 px-6 py-4 items-center">
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{t("fields.role")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{t(`users.roles.${user.role}`)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 px-6 py-4 items-center">
|
<div className="grid grid-cols-2 px-6 py-4 items-center">
|
||||||
<Text size="small" leading="compact" weight="plus">
|
<Text size="small" leading="compact" weight="plus">
|
||||||
{t("profile.language")}
|
{t("profile.language")}
|
||||||
|
|||||||
+19
-15
@@ -1,5 +1,5 @@
|
|||||||
import { User } from "@medusajs/medusa"
|
import { User } from "@medusajs/medusa"
|
||||||
import { Button, Container, Heading, Text } from "@medusajs/ui"
|
import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
|
|
||||||
@@ -9,35 +9,39 @@ type UserGeneralSection = {
|
|||||||
|
|
||||||
export const UserGeneralSection = ({ user }: UserGeneralSection) => {
|
export const UserGeneralSection = ({ user }: UserGeneralSection) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const name = [user.first_name, user.last_name].filter(Boolean).join(" ")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="divide-y p-0">
|
<Container className="p-0 divide-y">
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<div>
|
<Heading>{user.email}</Heading>
|
||||||
<Heading>{t("profile.domain")}</Heading>
|
|
||||||
<Text className="text-ui-fg-subtle" size="small">
|
|
||||||
{t("profile.manageYourProfileDetails")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Link to={`edit`}>
|
<Link to={`edit`}>
|
||||||
<Button size="small" variant="secondary">
|
<Button size="small" variant="secondary">
|
||||||
{t("profile.editProfile")}
|
{t("general.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 px-6 py-4">
|
<div className="grid grid-cols-2 px-6 py-4 items-center">
|
||||||
<Text size="small" leading="compact" weight="plus">
|
<Text size="small" leading="compact" weight="plus">
|
||||||
{t("fields.name")}
|
{t("fields.name")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="small" leading="compact">
|
<Text
|
||||||
{user.first_name} {user.last_name}
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
className={clx({
|
||||||
|
"text-ui-fg-subtle": !name,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{name ?? "-"}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 px-6 py-4">
|
<div className="grid grid-cols-2 px-6 py-4 items-center">
|
||||||
<Text size="small" leading="compact" weight="plus">
|
<Text size="small" leading="compact" weight="plus">
|
||||||
{t("fields.email")}
|
{t("fields.role")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="small" leading="compact">
|
<Text size="small" leading="compact">
|
||||||
{user.email}
|
{t(`users.roles.${user.role}`)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export { userLoader as loader } from "./loader"
|
||||||
export { UserDetail as Component } from "./user-detail"
|
export { UserDetail as Component } from "./user-detail"
|
||||||
@@ -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<Response<AdminUserRes>>(query.queryKey) ??
|
||||||
|
(await queryClient.fetchQuery(query))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useAdminUser } from "medusa-react"
|
import { useAdminUser } from "medusa-react"
|
||||||
import { Outlet, json, useParams } from "react-router-dom"
|
import { Outlet, json, useLoaderData, useParams } from "react-router-dom"
|
||||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||||
import { UserGeneralSection } from "./components/user-general-section"
|
import { UserGeneralSection } from "./components/user-general-section"
|
||||||
|
import { userLoader } from "./loader"
|
||||||
|
|
||||||
export const UserDetail = () => {
|
export const UserDetail = () => {
|
||||||
|
const initialData = useLoaderData() as Awaited<ReturnType<typeof userLoader>>
|
||||||
|
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { user, isLoading, isError, error } = useAdminUser(id!)
|
const { user, isLoading, isError, error } = useAdminUser(id!, {
|
||||||
|
initialData,
|
||||||
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div>Loading...</div>
|
return <div>Loading...</div>
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { User } from "@medusajs/medusa"
|
||||||
|
import { Button, Drawer, Input } from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateUser } from "medusa-react"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import * as zod from "zod"
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
|
||||||
|
type EditUserFormProps = {
|
||||||
|
user: Omit<User, "password_hash">
|
||||||
|
subscribe: (state: boolean) => void
|
||||||
|
onSuccessfulSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditUserFormSchema = zod.object({
|
||||||
|
first_name: zod.string().optional(),
|
||||||
|
last_name: zod.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const EditUserForm = ({
|
||||||
|
user,
|
||||||
|
subscribe,
|
||||||
|
onSuccessfulSubmit,
|
||||||
|
}: EditUserFormProps) => {
|
||||||
|
const form = useForm<zod.infer<typeof EditUserFormSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
first_name: user.first_name || "",
|
||||||
|
last_name: user.last_name || "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(EditUserFormSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { isDirty },
|
||||||
|
} = form
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subscribe(isDirty)
|
||||||
|
}, [isDirty])
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateUser(user.id)
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
|
await mutateAsync(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccessfulSubmit()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col overflow-hidden flex-1"
|
||||||
|
>
|
||||||
|
<Drawer.Body className="flex flex-col gap-y-8 overflow-y-auto flex-1 max-w-full">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="first_name"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.firstName")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="last_name"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.lastName")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Drawer.Body>
|
||||||
|
<Drawer.Footer>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<Drawer.Close asChild>
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
{t("general.cancel")}
|
||||||
|
</Button>
|
||||||
|
</Drawer.Close>
|
||||||
|
<Button size="small" type="submit" isLoading={isLoading}>
|
||||||
|
{t("general.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Drawer.Footer>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-user-form"
|
||||||
@@ -1,12 +1,39 @@
|
|||||||
import { Drawer } from "@medusajs/ui"
|
import { Drawer, Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminUser } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||||
|
import { EditUserForm } from "./components/edit-user-form"
|
||||||
|
|
||||||
export const UserEdit = () => {
|
export const UserEdit = () => {
|
||||||
const [open, onOpenChange] = useRouteModalState()
|
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { id } = useParams()
|
||||||
|
const { user, isLoading, isError, error } = useAdminUser(id!)
|
||||||
|
|
||||||
|
const handleSuccessfulSubmit = () => {
|
||||||
|
onOpenChange(false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
<Drawer.Content></Drawer.Content>
|
<Drawer.Content>
|
||||||
|
<Drawer.Header>
|
||||||
|
<Heading>{t("users.editUser")}</Heading>
|
||||||
|
</Drawer.Header>
|
||||||
|
{!isLoading && user && (
|
||||||
|
<EditUserForm
|
||||||
|
user={user}
|
||||||
|
subscribe={subscribe}
|
||||||
|
onSuccessfulSubmit={handleSuccessfulSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer.Content>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+436
@@ -0,0 +1,436 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { ArrowPath, Link, XCircle } from "@medusajs/icons"
|
||||||
|
import { Invite } from "@medusajs/medusa"
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
FocusModal,
|
||||||
|
Heading,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
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 {
|
||||||
|
useAdminCreateInvite,
|
||||||
|
useAdminDeleteInvite,
|
||||||
|
useAdminInvites,
|
||||||
|
useAdminResendInvite,
|
||||||
|
useAdminStore,
|
||||||
|
} from "medusa-react"
|
||||||
|
import { useEffect, 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"
|
||||||
|
|
||||||
|
type InviteUserFormProps = {
|
||||||
|
subscribe: (state: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
MEMBER = "member",
|
||||||
|
DEVELOPER = "developer",
|
||||||
|
ADMIN = "admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
const InviteUserSchema = zod.object({
|
||||||
|
user: zod.string().email(),
|
||||||
|
role: zod.nativeEnum(UserRole),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
export const InviteUserForm = ({ subscribe }: InviteUserFormProps) => {
|
||||||
|
const form = useForm<zod.infer<typeof InviteUserSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
user: "",
|
||||||
|
role: UserRole.MEMBER,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(InviteUserSchema),
|
||||||
|
})
|
||||||
|
const { mutateAsync, isLoading: isMutating } = useAdminCreateInvite()
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { isDirty },
|
||||||
|
} = form
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subscribe(isDirty)
|
||||||
|
}, [isDirty])
|
||||||
|
|
||||||
|
const { invites, isLoading, isError, error } = useAdminInvites()
|
||||||
|
const count = invites?.length ?? 0
|
||||||
|
|
||||||
|
const noRecords = !isLoading && count === 0
|
||||||
|
|
||||||
|
const columns = useColumns()
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: invites ?? [],
|
||||||
|
columns,
|
||||||
|
pageCount: Math.ceil(count / PAGE_SIZE),
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
role: values.role,
|
||||||
|
user: values.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex h-full flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<FocusModal.Header>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<FocusModal.Close asChild>
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
{t("general.close")}
|
||||||
|
</Button>
|
||||||
|
</FocusModal.Close>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Header>
|
||||||
|
<FocusModal.Body className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex flex-1 flex-col items-center overflow-y-auto">
|
||||||
|
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-2 py-16">
|
||||||
|
<div>
|
||||||
|
<Heading>{t("users.inviteUser")}</Heading>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
{t("users.inviteUserHint")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.email")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field: { ref, onChange, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.role")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Select {...field} onValueChange={onChange}>
|
||||||
|
<Select.Trigger ref={ref}>
|
||||||
|
<Select.Value />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{Object.values(UserRole).map((role) => (
|
||||||
|
<Select.Item key={role} value={role}>
|
||||||
|
{t(`users.roles.${role}`)}
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isMutating}
|
||||||
|
>
|
||||||
|
{t("users.sendInvite")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<Heading level="h2">{t("users.pendingInvites")}</Heading>
|
||||||
|
<Container className="p-0 overflow-hidden">
|
||||||
|
{!noRecords ? (
|
||||||
|
<div>
|
||||||
|
<Table>
|
||||||
|
<Table.Header className="border-t-0">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => {
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
key={headerGroup.id}
|
||||||
|
className="[&_th]:w-1/3 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<Table.HeaderCell key={header.id}>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</Table.HeaderCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<Table.Row
|
||||||
|
key={row.id}
|
||||||
|
className={clx(
|
||||||
|
"transition-fg[&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||||
|
{
|
||||||
|
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||||
|
row.getIsSelected(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<Table.Cell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
))}
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
<LocalizedTablePagination
|
||||||
|
canNextPage={table.getCanNextPage()}
|
||||||
|
canPreviousPage={table.getCanPreviousPage()}
|
||||||
|
nextPage={table.nextPage}
|
||||||
|
previousPage={table.previousPage}
|
||||||
|
count={count}
|
||||||
|
pageIndex={table.getState().pagination.pageIndex}
|
||||||
|
pageCount={table.getPageCount()}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NoRecords className="h-[200px]" />
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Body>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const InviteActions = ({ invite }: { invite: Invite }) => {
|
||||||
|
const { mutateAsync: revokeAsync } = useAdminDeleteInvite(invite.id)
|
||||||
|
const { mutateAsync: resendAsync } = useAdminResendInvite(invite.id)
|
||||||
|
const { store, isLoading, isError, error } = useAdminStore()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleRevoke = async () => {
|
||||||
|
const res = await prompt({
|
||||||
|
title: t("general.areYouSure"),
|
||||||
|
description: t("users.revokeInviteWarning", {
|
||||||
|
email: invite.user_email,
|
||||||
|
}),
|
||||||
|
cancelText: t("general.cancel"),
|
||||||
|
confirmText: t("general.confirm"),
|
||||||
|
})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <Link />,
|
||||||
|
label: t("users.copyInviteLink"),
|
||||||
|
disabled: isLoading || !store?.invite_link_template,
|
||||||
|
onClick: handleCopyInviteLink,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ArrowPath />,
|
||||||
|
label: t("users.resendInvite"),
|
||||||
|
onClick: handleResend,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <XCircle />,
|
||||||
|
label: t("general.revoke"),
|
||||||
|
onClick: handleRevoke,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Invite>()
|
||||||
|
|
||||||
|
const useColumns = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor("user_email", {
|
||||||
|
header: t("fields.email"),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
return getValue()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("role", {
|
||||||
|
header: t("fields.role"),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
return t(`users.roles.${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 (
|
||||||
|
<Tooltip
|
||||||
|
content={t("users.acceptedOnDate", {
|
||||||
|
date: format(
|
||||||
|
new Date(row.original.updated_at),
|
||||||
|
"dd MMM, yyyy"
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<StatusBadge color="green">
|
||||||
|
{t("users.inviteStatus.accepted")}
|
||||||
|
</StatusBadge>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={t("users.expiredOnDate", {
|
||||||
|
date: format(
|
||||||
|
new Date(row.original.expires_at),
|
||||||
|
"dd MMM, yyyy"
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<StatusBadge color="red">
|
||||||
|
{t("users.inviteStatus.expired")}
|
||||||
|
</StatusBadge>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<Trans
|
||||||
|
i18nKey={"users.validFromUntil"}
|
||||||
|
components={[
|
||||||
|
<span className="font-medium" />,
|
||||||
|
<span className="font-medium" />,
|
||||||
|
]}
|
||||||
|
values={{
|
||||||
|
from: format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"dd MMM, yyyy"
|
||||||
|
),
|
||||||
|
until: format(
|
||||||
|
new Date(row.original.expires_at),
|
||||||
|
"dd MMM, yyyy"
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StatusBadge color="orange">
|
||||||
|
{t("users.inviteStatus.pending")}
|
||||||
|
</StatusBadge>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => <InviteActions invite={row.original} />,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { FocusModal } from "@medusajs/ui"
|
import { FocusModal } from "@medusajs/ui"
|
||||||
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||||
|
import { InviteUserForm } from "./components/invite-user-form/invite-user-form"
|
||||||
|
|
||||||
export const UserInvite = () => {
|
export const UserInvite = () => {
|
||||||
const [open, onOpenChange] = useRouteModalState()
|
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusModal open={open} onOpenChange={onOpenChange}>
|
<FocusModal open={open} onOpenChange={onOpenChange}>
|
||||||
<FocusModal.Content></FocusModal.Content>
|
<FocusModal.Content>
|
||||||
|
<InviteUserForm subscribe={subscribe} />
|
||||||
|
</FocusModal.Content>
|
||||||
</FocusModal>
|
</FocusModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+136
-53
@@ -1,7 +1,8 @@
|
|||||||
|
import { PencilSquare } from "@medusajs/icons"
|
||||||
import { User } from "@medusajs/medusa"
|
import { User } from "@medusajs/medusa"
|
||||||
import { Checkbox, Container, Heading, Table, clx } from "@medusajs/ui"
|
import { Button, Container, Heading, Table, clx } from "@medusajs/ui"
|
||||||
import {
|
import {
|
||||||
RowSelectionState,
|
PaginationState,
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -10,28 +11,67 @@ import {
|
|||||||
import { useAdminUsers } from "medusa-react"
|
import { useAdminUsers } from "medusa-react"
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useNavigate } from "react-router-dom"
|
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 = () => {
|
export const UserListTable = () => {
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
const { users, isLoading, isError, error } = useAdminUsers()
|
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 columns = useColumns()
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: users ?? [],
|
data: users ?? [],
|
||||||
columns,
|
columns,
|
||||||
|
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
|
||||||
state: {
|
state: {
|
||||||
rowSelection,
|
pagination,
|
||||||
},
|
},
|
||||||
onRowSelectionChange: setRowSelection,
|
onPaginationChange: setPagination,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const noRecords =
|
||||||
|
!isLoading &&
|
||||||
|
!users?.length &&
|
||||||
|
!Object.values(params).filter(Boolean).length
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -40,14 +80,44 @@ export const UserListTable = () => {
|
|||||||
<Container className="p-0 divide-y">
|
<Container className="p-0 divide-y">
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<Heading level="h2">{t("users.domain")}</Heading>
|
<Heading level="h2">{t("users.domain")}</Heading>
|
||||||
|
<Button size="small" variant="secondary" asChild>
|
||||||
|
<Link to="invite">{t("general.invite")}</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{!noRecords && (
|
||||||
|
<div className="px-6 py-4 flex items-center justify-between">
|
||||||
|
<div></div>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Query />
|
||||||
|
<OrderBy
|
||||||
|
keys={[
|
||||||
|
"email",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"role",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{noRecords ? (
|
||||||
|
<NoRecords />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{!isLoading && !users?.length ? (
|
||||||
|
<div className="border-b">
|
||||||
|
<NoResults />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<Table.Header className="border-t-0">
|
<Table.Header className="border-t-0">
|
||||||
{table.getHeaderGroups().map((headerGroup) => {
|
{table.getHeaderGroups().map((headerGroup) => {
|
||||||
return (
|
return (
|
||||||
<Table.Row
|
<Table.Row
|
||||||
key={headerGroup.id}
|
key={headerGroup.id}
|
||||||
className="[&_th]:w-1/3 [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
|
className="[&_th]:w-1/3 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
@@ -63,7 +133,7 @@ export const UserListTable = () => {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body className="border-b-0">
|
<Table.Body>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<Table.Row
|
<Table.Row
|
||||||
key={row.id}
|
key={row.id}
|
||||||
@@ -78,18 +148,53 @@ export const UserListTable = () => {
|
|||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<Table.Cell key={cell.id}>
|
<Table.Cell key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
))}
|
))}
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
<div className="px-6 py-4"></div>
|
)}
|
||||||
|
<LocalizedTablePagination
|
||||||
|
canNextPage={table.getCanNextPage()}
|
||||||
|
canPreviousPage={table.getCanPreviousPage()}
|
||||||
|
nextPage={table.nextPage}
|
||||||
|
previousPage={table.previousPage}
|
||||||
|
count={count ?? 0}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageCount={table.getPageCount()}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UserActions = ({ user }: { user: Omit<User, "password_hash"> }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
label: t("general.edit"),
|
||||||
|
to: `${user.id}/edit`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Omit<User, "password_hash">>()
|
const columnHelper = createColumnHelper<Omit<User, "password_hash">>()
|
||||||
|
|
||||||
const useColumns = () => {
|
const useColumns = () => {
|
||||||
@@ -97,59 +202,37 @@ const useColumns = () => {
|
|||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => [
|
() => [
|
||||||
columnHelper.display({
|
|
||||||
id: "select",
|
|
||||||
header: ({ table }) => {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsSomePageRowsSelected()
|
|
||||||
? "indeterminate"
|
|
||||||
: table.getIsAllPageRowsSelected()
|
|
||||||
}
|
|
||||||
onCheckedChange={(value) =>
|
|
||||||
table.toggleAllPageRowsSelected(!!value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.display({
|
|
||||||
id: "name",
|
|
||||||
header: t("fields.name"),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const { first_name, last_name } = row.original
|
|
||||||
|
|
||||||
if (!first_name && !last_name) {
|
|
||||||
return <span className="text-ui-fg-muted">-</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${first_name || ""} ${last_name || ""}`.trim()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("email", {
|
columnHelper.accessor("email", {
|
||||||
header: t("fields.email"),
|
header: t("fields.email"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return row.original.email
|
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 <span className="text-ui-fg-muted">-</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
},
|
||||||
|
}),
|
||||||
columnHelper.accessor("role", {
|
columnHelper.accessor("role", {
|
||||||
header: t("users.role"),
|
header: t("fields.role"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return t(`users.roles.${row.original.role}`)
|
return t(`users.roles.${row.original.role}`)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => <UserActions user={row.original} />,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
[t]
|
[t]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { Outlet } from "react-router-dom"
|
||||||
import { UserListTable } from "./components/user-list-table"
|
import { UserListTable } from "./components/user-list-table"
|
||||||
|
|
||||||
export const UserList = () => {
|
export const UserList = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-y-2">
|
<div className="flex flex-col gap-y-2">
|
||||||
<UserListTable />
|
<UserListTable />
|
||||||
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user