feat(dashboard): Pub Api Key domain (#6162)

**What**
- Adds Pub. Api Key domain

**Note**
- Pagination for sales channels associated with the pubkey is not implemented correctly as it is not supported by the API. Will open a separate issue on this, and revisit the impl. once fixed.

CLOSES CORE-1656
This commit is contained in:
Kasper Fabricius Kristensen
2024-01-23 20:15:16 +00:00
committed by GitHub
parent 68d8daccd2
commit c37c82c5b5
30 changed files with 1421 additions and 111 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/ui": patch
---
feature(ui): Adds a `size` variant to `<Copy />` component, and prevent clicks from propigating to parent elements". Also adds additional sizes to the `<Avatar />` component.
@@ -27,6 +27,8 @@
"details": "Details",
"enabled": "Enabled",
"disabled": "Disabled",
"active": "Active",
"revoked": "Revoked",
"remove": "Remove",
"admin": "Admin",
"store": "Store",
@@ -183,10 +185,16 @@
"domain": "API Key Management",
"createKey": "Create key",
"createPublishableApiKey": "Create Publishable API Key",
"editKey": "Edit key",
"revoke": "Revoke",
"publishableApiKeyHint": "Publishable API keys are used to limit the scope of requests to specific sales channels.",
"deleteKeyWarning": "You are about to delete the API key {{title}}. This action cannot be undone.",
"revokeKeyWarning": "You are about to revoke the API key {{title}}."
"revokeKeyWarning": "You are about to revoke the API key {{title}}. This action cannot be undone, and the key cannot be used in future requests.",
"removeSalesChannelWarning": "You are about to remove the sales channel {{name}} from the API key. This action cannot be undone.",
"removeSalesChannelsWarning_one": "You are about to remove {{count}} sales channel from the API key. This action cannot be undone.",
"removeSalesChannelsWarning_other": "You are about to remove {{count}} sales channels from the API key. This action cannot be undone.",
"createdBy": "Created by",
"revokedBy": "Revoked by"
},
"fields": {
"name": "Name",
@@ -234,6 +242,8 @@
"variants": "Variants",
"orders": "Orders",
"account": "Account",
"total": "Total"
"total": "Total",
"created": "Created",
"key": "Key"
}
}
@@ -0,0 +1 @@
export * from "./user-link"
@@ -0,0 +1,34 @@
import { Avatar, Text } from "@medusajs/ui"
import { Link } from "react-router-dom"
type UserLinkProps = {
id: string
first_name?: string | null
last_name?: string | null
email: string
type?: "customer" | "user"
}
export const UserLink = ({
id,
first_name,
last_name,
email,
type = "user",
}: UserLinkProps) => {
const name = [first_name, last_name].filter(Boolean).join(" ")
const fallback = name ? name.slice(0, 1) : email.slice(0, 1)
const link = type === "user" ? `/settings/users/${id}` : `/customers/${id}`
return (
<Link
to={link}
className="flex items-center gap-x-2 w-fit transition-fg hover:text-ui-fg-subtle outline-none focus-visible:shadow-borders-focus rounded-md"
>
<Avatar size="2xsmall" fallback={fallback.toUpperCase()} />
<Text size="small" leading="compact" weight="regular">
{name || email}
</Text>
</Link>
)
}
@@ -3,7 +3,9 @@ import type {
AdminCustomerGroupsRes,
AdminCustomersRes,
AdminProductsRes,
AdminPublishableApiKeysRes,
AdminRegionsRes,
AdminSalesChannelsRes,
} from "@medusajs/medusa"
import {
Outlet,
@@ -487,6 +489,10 @@ const router = createBrowserRouter([
path: ":id",
lazy: () =>
import("../../routes/sales-channels/sales-channel-detail"),
handle: {
crumb: (data: AdminSalesChannelsRes) =>
data.sales_channel.name,
},
children: [
{
path: "edit",
@@ -533,6 +539,10 @@ const router = createBrowserRouter([
import(
"../../routes/api-key-management/api-key-management-detail"
),
handle: {
crumb: (data: AdminPublishableApiKeysRes) =>
data.publishable_api_key.title,
},
children: [
{
path: "edit",
@@ -541,6 +551,13 @@ const router = createBrowserRouter([
"../../routes/api-key-management/api-key-management-edit"
),
},
{
path: "add-sales-channels",
lazy: () =>
import(
"../../routes/api-key-management/api-key-management-add-sales-channels"
),
},
],
},
],
@@ -0,0 +1,36 @@
import { FocusModal } from "@medusajs/ui"
import { useAdminPublishableApiKeySalesChannels } from "medusa-react"
import { useParams } from "react-router-dom"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { AddSalesChannelsToApiKeyForm } from "./components"
export const ApiKeyManagementAddSalesChannels = () => {
const { id } = useParams()
const [open, onOpenChange, subscribe] = useRouteModalState()
const { sales_channels, isLoading, isError, error } =
useAdminPublishableApiKeySalesChannels(id!)
const handleSuccessfulSubmit = () => {
onOpenChange(false, true)
}
if (isError) {
throw error
}
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content>
{!isLoading && sales_channels && (
<AddSalesChannelsToApiKeyForm
apiKey={id!}
preSelected={sales_channels.map((sc) => sc.id)}
onSuccessfulSubmit={handleSuccessfulSubmit}
subscribe={subscribe}
/>
)}
</FocusModal.Content>
</FocusModal>
)
}
@@ -0,0 +1,326 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { SalesChannel } from "@medusajs/medusa"
import {
Button,
Checkbox,
FocusModal,
Hint,
StatusBadge,
Table,
Tooltip,
clx,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
useAdminAddPublishableKeySalesChannelsBatch,
useAdminSalesChannels,
} from "medusa-react"
import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../components/common/form"
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"
type AddSalesChannelsToApiKeyFormProps = {
apiKey: string
preSelected: string[]
subscribe: (state: boolean) => void
onSuccessfulSubmit: () => void
}
const AddSalesChannelsToApiKeySchema = zod.object({
sales_channel_ids: zod.array(zod.string()).min(1),
})
const PAGE_SIZE = 50
export const AddSalesChannelsToApiKeyForm = ({
apiKey,
preSelected,
subscribe,
onSuccessfulSubmit,
}: AddSalesChannelsToApiKeyFormProps) => {
const { t } = useTranslation()
const form = useForm<zod.infer<typeof AddSalesChannelsToApiKeySchema>>({
defaultValues: {
sales_channel_ids: [],
},
resolver: zodResolver(AddSalesChannelsToApiKeySchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { mutateAsync, isLoading: isMutating } =
useAdminAddPublishableKeySalesChannelsBatch(apiKey)
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
useEffect(() => {
form.setValue(
"sales_channel_ids",
Object.keys(rowSelection).filter((k) => rowSelection[k])
)
}, [rowSelection])
const params = useQueryParams(["q", "order"])
const { sales_channels, count } = useAdminSalesChannels(
{
limit: PAGE_SIZE,
offset: PAGE_SIZE * pageIndex,
...params,
},
{
keepPreviousData: true,
}
)
const columns = useColumns()
const table = useReactTable({
data: sales_channels ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
getRowId: (row) => row.id,
enableRowSelection: (row) => {
return !preSelected.includes(row.id)
},
meta: {
preSelected,
},
})
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{
sales_channel_ids: values.sales_channel_ids.map((p) => ({ id: p })),
},
{
onSuccess: () => {
onSuccessfulSubmit()
},
}
)
})
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">
{form.formState.errors.sales_channel_ids && (
<Hint variant="error">
{form.formState.errors.sales_channel_ids.message}
</Hint>
)}
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
</Button>
</FocusModal.Close>
<Button size="small" type="submit" isLoading={isMutating}>
{t("general.save")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto divide-y">
<div className="flex items-center justify-between w-full px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
<OrderBy keys={["title"]} />
</div>
</div>
<div className="w-full flex-1 overflow-y-auto">
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-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 className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
},
{
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
preSelected.includes(row.original.id),
}
)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
<div className="w-full border-t">
<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>
</FocusModal.Body>
</form>
</Form>
)
}
const columnHelper = createColumnHelper<SalesChannel>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row, table }) => {
const { preSelected } = table.options.meta as {
preSelected: string[]
}
const isAdded = preSelected.includes(row.original.id)
const isSelected = row.getIsSelected() || isAdded
const Component = (
<Checkbox
checked={isSelected}
disabled={isAdded}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
)
if (isAdded) {
return (
<Tooltip
content={t("salesChannels.productAlreadyAdded")}
side="right"
>
{Component}
</Tooltip>
)
}
return Component
},
}),
columnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.accessor("description", {
header: t("fields.description"),
cell: ({ getValue }) => (
<div className="w-[200px] truncate">
<span>{getValue()}</span>
</div>
),
}),
columnHelper.accessor("is_disabled", {
header: t("fields.status"),
cell: ({ getValue }) => {
const value = getValue()
return (
<div>
<StatusBadge color={value ? "grey" : "green"}>
{value ? t("general.disabled") : t("general.enabled")}
</StatusBadge>
</div>
)
},
}),
],
[t]
)
}
@@ -0,0 +1 @@
export * from "./add-sales-channels-to-api-key-form"
@@ -0,0 +1 @@
export { ApiKeyManagementAddSalesChannels as Component } from "./api-key-management-add-sales-channels"
@@ -8,7 +8,7 @@ export const ApiKeyManagementCreate = () => {
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content>
<CreatePublishableApiKeyForm />
<CreatePublishableApiKeyForm subscribe={subscribe} />
</FocusModal.Content>
</FocusModal>
)
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { Form } from "../../../../../components/common/form"
type CreatePublishableApiKeyFormProps = {
@@ -37,9 +38,16 @@ export const CreatePublishableApiKeyForm = ({
}, [isDirty])
const { t } = useTranslation()
const navigate = useNavigate()
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(values)
await mutateAsync(values, {
onSuccess: ({ publishable_api_key }) => {
navigate(`/settings/api-key-management/${publishable_api_key.id}`, {
replace: true,
})
},
})
})
return (
@@ -1,3 +1,39 @@
import { useAdminPublishableApiKey } from "medusa-react"
import { Outlet, json, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { ApiKeyGeneralSection } from "./components/api-key-general-section"
import { ApiKeySalesChannelSection } from "./components/api-key-sales-channel-section"
import { apiKeyLoader } from "./loader"
export const ApiKeyManagementDetail = () => {
return <div className="flex flex-col gap-y-2"></div>
const initialData = useLoaderData() as Awaited<
ReturnType<typeof apiKeyLoader>
>
const { id } = useParams()
const { publishable_api_key, isLoading, isError, error } =
useAdminPublishableApiKey(id!, {
initialData,
})
if (isLoading) {
return <div>Loading...</div>
}
if (isError || !publishable_api_key) {
if (error) {
throw error
}
throw json("An unknown error occurred", 500)
}
return (
<div className="flex flex-col gap-y-2">
<ApiKeyGeneralSection apiKey={publishable_api_key} />
<ApiKeySalesChannelSection apiKey={publishable_api_key} />
<JsonViewSection data={publishable_api_key} />
<Outlet />
</div>
)
}
@@ -0,0 +1,180 @@
import { PencilSquare, Trash, XCircle } from "@medusajs/icons"
import { PublishableApiKey } from "@medusajs/medusa"
import {
Container,
Copy,
Heading,
StatusBadge,
Text,
usePrompt,
} from "@medusajs/ui"
import {
useAdminDeletePublishableApiKey,
useAdminRevokePublishableApiKey,
useAdminUser,
} from "medusa-react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Skeleton } from "../../../../../components/common/skeleton"
import { UserLink } from "../../../../../components/common/user-link"
type ApiKeyGeneralSectionProps = {
apiKey: PublishableApiKey
}
export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync: revokeAsync } = useAdminRevokePublishableApiKey(
apiKey.id
)
const { mutateAsync: deleteAsync } = useAdminDeletePublishableApiKey(
apiKey.id
)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.deleteKeyWarning", {
title: apiKey.title,
}),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await deleteAsync()
}
const handleRevoke = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.revokeKeyWarning", {
title: apiKey.title,
}),
confirmText: t("apiKeyManagement.revoke"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await revokeAsync()
}
const dangerousActions = [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
]
if (!apiKey.revoked_at) {
dangerousActions.unshift({
icon: <XCircle />,
label: t("apiKeyManagement.revoke"),
onClick: handleRevoke,
})
}
return (
<Container className="p-0 divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<Heading>{apiKey.title}</Heading>
<div className="flex items-center gap-x-2">
<StatusBadge color={apiKey.revoked_at ? "red" : "green"}>
{apiKey.revoked_at ? t("general.revoked") : t("general.active")}
</StatusBadge>
<ActionMenu
groups={[
{
actions: [
{
label: t("general.edit"),
icon: <PencilSquare />,
to: `/settings/api-key-management/${apiKey.id}/edit`,
},
],
},
{
actions: dangerousActions,
},
]}
/>
</div>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("fields.key")}
</Text>
<div className="bg-ui-bg-subtle border border-ui-border-base flex items-center gap-x-0.5 w-fit rounded-full pl-2 pr-1 box-border cursor-default overflow-hidden">
<Text size="xsmall" leading="compact" className="truncate">
{apiKey.id}
</Text>
<Copy
content={apiKey.id}
variant="mini"
className="text-ui-fg-subtle"
/>
</div>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("apiKeyManagement.createdBy")}
</Text>
<ActionBy userId={apiKey.created_by} />
</div>
{apiKey.revoked_at && (
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("apiKeyManagement.revokedBy")}
</Text>
<ActionBy userId={apiKey.revoked_by} />
</div>
)}
</Container>
)
}
const ActionBy = ({ userId }: { userId: string | null }) => {
const { user, isLoading, isError, error } = useAdminUser(userId!, {
enabled: !!userId,
})
if (!userId) {
return (
<Text size="small" className="text-ui-fg-subtle">
-
</Text>
)
}
if (isError) {
throw error
}
if (isLoading) {
return (
<div className="grid grid-cols-[20px_1fr]">
<Skeleton className="w-5 h-5 rounded-full" />
<Skeleton className="max-w-[220px] w-full" />
</div>
)
}
if (!user) {
return (
<Text size="small" className="text-ui-fg-subtle">
-
</Text>
)
}
return <UserLink {...user} />
}
@@ -0,0 +1,368 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { PublishableApiKey, SalesChannel } from "@medusajs/medusa"
import {
Button,
Checkbox,
CommandBar,
Container,
Heading,
StatusBadge,
Table,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
useAdminPublishableApiKeySalesChannels,
useAdminRemovePublishableKeySalesChannelsBatch,
} 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 { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
type ApiKeySalesChannelSectionProps = {
apiKey: PublishableApiKey
}
const PAGE_SIZE = 10
export const ApiKeySalesChannelSection = ({
apiKey,
}: ApiKeySalesChannelSectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const prompt = usePrompt()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const params = useQueryParams(["q"])
const { sales_channels, isLoading, isError, error } =
useAdminPublishableApiKeySalesChannels(
apiKey.id,
{
...params,
},
{
keepPreviousData: true,
}
)
const count = sales_channels?.length || 0
const columns = useColumns()
const table = useReactTable({
data: sales_channels ?? [],
columns,
pageCount: Math.ceil(count / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
getRowId: (row) => row.id,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
meta: {
apiKey: apiKey.id,
},
})
const { mutateAsync } = useAdminRemovePublishableKeySalesChannelsBatch(
apiKey.id
)
const handleRemove = async () => {
const keys = Object.keys(rowSelection).filter((k) => rowSelection[k])
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.removeSalesChannelsWarning", {
count: keys.length,
}),
confirmText: t("general.continue"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync(
{
sales_channel_ids: keys.map((k) => ({ id: k })),
},
{
onSuccess: () => {
setRowSelection({})
},
}
)
}
const noRecords = !isLoading && !sales_channels?.length && !params.q
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<Heading level="h2">{t("salesChannels.domain")}</Heading>
<Button variant="secondary" size="small" asChild>
<Link to="add-sales-channels">{t("general.add")}</Link>
</Button>
</div>
{!noRecords && (
<div className="px-6 py-4 flex items-center justify-between">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
</div>
</div>
)}
{noRecords ? (
<NoRecords />
) : (
<div>
{!isLoading && sales_channels?.length !== 0 ? (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{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 className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() =>
navigate(`/settings/sales-channels/${row.original.id}`)
}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<NoResults />
)}
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
<CommandBar open={!!Object.keys(rowSelection).length}>
<CommandBar.Bar>
<CommandBar.Value>
{t("general.countSelected", {
count: Object.keys(rowSelection).length,
})}
</CommandBar.Value>
<CommandBar.Seperator />
<CommandBar.Command
action={handleRemove}
shortcut="r"
label={t("general.remove")}
/>
</CommandBar.Bar>
</CommandBar>
</div>
)}
</Container>
)
}
const SalesChannelActions = ({
salesChannel,
apiKey,
}: {
salesChannel: SalesChannel
apiKey: string
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminRemovePublishableKeySalesChannelsBatch(apiKey)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.removeSalesChannelWarning"),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync({
sales_channel_ids: [{ id: salesChannel.id }],
})
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/settings/sales-channels/${salesChannel.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<SalesChannel>()
const useColumns = () => {
const { t } = useTranslation()
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.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.accessor("description", {
header: t("fields.description"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.accessor("is_disabled", {
header: t("fields.status"),
cell: ({ getValue }) => {
const value = getValue()
return (
<div>
<StatusBadge color={value ? "grey" : "green"}>
{value ? t("general.disabled") : t("general.enabled")}
</StatusBadge>
</div>
)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
const { apiKey } = table.options.meta as {
apiKey: string
}
return (
<SalesChannelActions salesChannel={row.original} apiKey={apiKey} />
)
},
}),
],
[t]
)
}
@@ -1 +1,2 @@
export { ApiKeyManagementDetail as Component } from "./api-key-management-detail"
export { apiKeyLoader as loader } from "./loader"
@@ -0,0 +1,22 @@
import { AdminPublishableApiKeysRes } 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 apiKeyDetailQuery = (id: string) => ({
queryKey: adminProductKeys.detail(id),
queryFn: async () => medusa.admin.publishableApiKeys.retrieve(id),
})
export const apiKeyLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = apiKeyDetailQuery(id!)
return (
queryClient.getQueryData<Response<AdminPublishableApiKeysRes>>(
query.queryKey
) ?? (await queryClient.fetchQuery(query))
)
}
@@ -1,12 +1,40 @@
import { Drawer } from "@medusajs/ui"
import { Drawer, Heading } from "@medusajs/ui"
import { useAdminPublishableApiKey } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { EditApiKeyForm } from "./components/edit-api-key-form"
export const ApiKeyManagementEdit = () => {
const [open, onOpenChange] = useRouteModalState()
const [open, onOpenChange, subscribe] = useRouteModalState()
const { id } = useParams()
const { t } = useTranslation()
const { publishable_api_key, isLoading, isError, error } =
useAdminPublishableApiKey(id!)
const handleSuccessfulSubmit = () => {
onOpenChange(false, true)
}
if (isError) {
throw error
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<Drawer.Content></Drawer.Content>
<Drawer.Content>
<Drawer.Header>
<Heading>{t("apiKeyManagement.editKey")}</Heading>
</Drawer.Header>
{!isLoading && publishable_api_key && (
<EditApiKeyForm
apiKey={publishable_api_key}
onSuccessfulSubmit={handleSuccessfulSubmit}
subscribe={subscribe}
/>
)}
</Drawer.Content>
</Drawer>
)
}
@@ -0,0 +1,90 @@
import { zodResolver } from "@hookform/resolvers/zod"
import type { PublishableApiKey } from "@medusajs/medusa"
import { Button, Drawer, Input } from "@medusajs/ui"
import { useAdminUpdatePublishableApiKey } 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 EditApiKeyFormProps = {
apiKey: PublishableApiKey
onSuccessfulSubmit: () => void
subscribe: (state: boolean) => void
}
const EditApiKeySchema = zod.object({
title: zod.string().min(1),
})
export const EditApiKeyForm = ({
apiKey,
onSuccessfulSubmit,
subscribe,
}: EditApiKeyFormProps) => {
const { t } = useTranslation()
const form = useForm<zod.infer<typeof EditApiKeySchema>>({
defaultValues: {
title: apiKey.title,
},
resolver: zodResolver(EditApiKeySchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { mutateAsync, isLoading } = useAdminUpdatePublishableApiKey(apiKey.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: () => {
onSuccessfulSubmit()
},
})
})
return (
<Form {...form}>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
<Drawer.Body>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</Drawer.Body>
<Drawer.Footer>
<div className="flex items-center 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,6 +1,16 @@
import { PencilSquare, Trash, XCircle } from "@medusajs/icons"
import { PublishableApiKey } from "@medusajs/medusa"
import { Button, Container, Heading, Table, clx, usePrompt } from "@medusajs/ui"
import {
Button,
Container,
Copy,
Heading,
StatusBadge,
Table,
Text,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
@@ -9,6 +19,7 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { format } from "date-fns"
import {
useAdminDeletePublishableApiKey,
useAdminPublishableApiKeys,
@@ -71,7 +82,7 @@ export const ApiKeyManagementListTable = () => {
}
return (
<Container className="p-0">
<Container className="p-0 divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<Heading level="h2">{t("apiKeyManagement.domain")}</Heading>
<Link to="create">
@@ -89,7 +100,7 @@ export const ApiKeyManagementListTable = () => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
className="[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/4"
>
{headerGroup.headers.map((header) => {
return (
@@ -247,7 +258,45 @@ const useColumns = () => {
}),
columnHelper.accessor("id", {
header: "Key",
cell: ({ getValue }) => getValue(),
cell: ({ getValue }) => {
const token = getValue()
return (
<div
className="bg-ui-bg-subtle border border-ui-border-base flex items-center gap-x-0.5 w-fit max-w-[220px] rounded-full pl-2 pr-1 box-border cursor-default overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<Text size="xsmall" leading="compact" className="truncate">
{token}
</Text>
<Copy
content={token}
variant="mini"
className="text-ui-fg-subtle"
/>
</div>
)
},
}),
columnHelper.accessor("revoked_at", {
header: t("fields.status"),
cell: ({ getValue }) => {
const revokedAt = getValue()
return (
<StatusBadge color={revokedAt ? "red" : "green"}>
{revokedAt ? t("general.revoked") : t("general.active")}
</StatusBadge>
)
},
}),
columnHelper.accessor("created_at", {
header: t("fields.created"),
cell: ({ getValue }) => {
const date = getValue()
return format(new Date(date), "dd MMM, yyyy")
},
}),
columnHelper.display({
id: "actions",
@@ -29,7 +29,7 @@ type LocationSalesChannelSectionProps = {
location: StockLocationExpandedDTO
}
const PAGE_SIZE = 20
const PAGE_SIZE = 10
export const LocationSalesChannelSection = ({
location,
@@ -1 +1,2 @@
export { salesChannelLoader as loader } from "./loader"
export { SalesChannelDetail as Component } from "./sales-channel-detail"
@@ -0,0 +1,21 @@
import { AdminSalesChannelsRes } 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 salesChannelDetailQuery = (id: string) => ({
queryKey: adminProductKeys.detail(id),
queryFn: async () => medusa.admin.salesChannels.retrieve(id),
})
export const salesChannelLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = salesChannelDetailQuery(id!)
return (
queryClient.getQueryData<Response<AdminSalesChannelsRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}
@@ -1,13 +1,20 @@
import { useAdminSalesChannel } from "medusa-react"
import { Outlet, useParams } from "react-router-dom"
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { SalesChannelGeneralSection } from "./components/sales-channel-general-section"
import { SalesChannelProductSection } from "./components/sales-channel-product-section"
import { salesChannelLoader } from "./loader"
export const SalesChannelDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof salesChannelLoader>
>
const { id } = useParams()
const { sales_channel, isLoading } = useAdminSalesChannel(id!)
const { sales_channel, isLoading } = useAdminSalesChannel(id!, {
initialData,
})
if (isLoading || !sales_channel) {
return <div>Loading...</div>
@@ -51,11 +51,16 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => {
{t("store.swapLinkTemplate")}
</Text>
{store.swap_link_template ? (
<Copy content={store.swap_link_template} asChild>
<Badge className="w-fit cursor-pointer" rounded="full">
<span>{store.swap_link_template}</span>
</Badge>
</Copy>
<div className="bg-ui-bg-subtle border border-ui-border-base flex items-center gap-x-0.5 w-fit rounded-full pl-2 pr-1 box-border cursor-default overflow-hidden">
<Text size="xsmall" leading="compact" className="truncate">
{store.swap_link_template}
</Text>
<Copy
content={store.swap_link_template}
variant="mini"
className="text-ui-fg-subtle"
/>
</div>
) : (
<Text size="small" leading="compact">
-
@@ -67,11 +72,16 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => {
{t("store.paymentLinkTemplate")}
</Text>
{store.payment_link_template ? (
<Copy content={store.payment_link_template} asChild>
<Badge className="w-fit cursor-pointer" rounded="full">
<span>{store.payment_link_template}</span>
</Badge>
</Copy>
<div className="bg-ui-bg-subtle border border-ui-border-base flex items-center gap-x-0.5 w-fit rounded-full pl-2 pr-1 box-border cursor-default overflow-hidden">
<Text size="xsmall" leading="compact" className="truncate">
{store.payment_link_template}
</Text>
<Copy
content={store.payment_link_template}
variant="mini"
className="text-ui-fg-subtle"
/>
</div>
) : (
<Text size="small" leading="compact">
-
@@ -83,11 +93,16 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => {
{t("store.inviteLinkTemplate")}
</Text>
{store.invite_link_template ? (
<Copy content={store.invite_link_template} asChild>
<Badge className="w-fit cursor-pointer" rounded="full">
<span>{store.invite_link_template}</span>
</Badge>
</Copy>
<div className="bg-ui-bg-subtle border border-ui-border-base flex items-center gap-x-0.5 w-fit rounded-full pl-2 pr-1 box-border cursor-default overflow-hidden">
<Text size="xsmall" leading="compact" className="truncate">
{store.invite_link_template}
</Text>
<Copy
content={store.invite_link_template}
variant="mini"
className="text-ui-fg-subtle"
/>
</div>
) : (
<Text size="small" leading="compact">
-
@@ -14,18 +14,24 @@ const avatarVariants = cva({
rounded: "rounded-full",
},
size: {
xsmall: "h-5 w-5",
small: "h-6 w-6",
"2xsmall": "h-5 w-5",
xsmall: "h-6 w-6",
small: "h-7 w-7",
base: "h-8 w-8",
large: "h-10 w-10",
xlarge: "h-12 w-12",
},
},
compoundVariants: [
{
variant: "squared",
size: "2xsmall",
className: "rounded-md",
},
{
variant: "squared",
size: "xsmall",
className: "rounded-[4px]",
className: "rounded-md",
},
{
variant: "squared",
@@ -62,14 +68,20 @@ const innerVariants = cva({
rounded: "rounded-full",
},
size: {
xsmall: "txt-compact-xsmall-plus h-4 w-4",
small: "txt-compact-xsmall-plus h-5 w-5",
"2xsmall": "txt-compact-xsmall-plus h-4 w-4",
xsmall: "txt-compact-xsmall-plus h-5 w-5",
small: "txt-compact-small-plus h-6 w-6",
base: "txt-compact-small-plus h-7 w-7",
large: "txt-compact-medium-plus h-9 w-9",
xlarge: "txt-compact-large-plus h-11 w-11",
},
},
compoundVariants: [
{
variant: "squared",
size: "2xsmall",
className: "rounded-sm",
},
{
variant: "squared",
size: "xsmall",
@@ -2,77 +2,115 @@
import { Tooltip } from "@/components/tooltip"
import { clx } from "@/utils/clx"
import { CheckCircleSolid, SquareTwoStack } from "@medusajs/icons"
import {
CheckCircleMiniSolid,
CheckCircleSolid,
SquareTwoStack,
SquareTwoStackMini,
} from "@medusajs/icons"
import { Slot } from "@radix-ui/react-slot"
import copy from "copy-to-clipboard"
import React, { useState } from "react"
type CopyProps = React.HTMLAttributes<HTMLButtonElement> & {
content: string
variant?: "mini" | "default" | null
asChild?: boolean
}
/**
* This component is based on the `button` element and supports all of its props
*/
const Copy = React.forwardRef<
HTMLButtonElement,
CopyProps
>(({
children,
className,
/**
* The content to copy.
*/
content,
/**
* Whether to remove the wrapper `button` element and use the
* passed child element instead.
*/
asChild = false,
...props
}: CopyProps, ref) => {
const [done, setDone] = useState(false)
const [open, setOpen] = useState(false)
const [text, setText] = useState("Copy")
const Copy = React.forwardRef<HTMLButtonElement, CopyProps>(
(
{
children,
className,
/**
* The content to copy.
*/
content,
/**
* The variant of the copy button.
*/
variant = "default",
/**
* Whether to remove the wrapper `button` element and use the
* passed child element instead.
*/
asChild = false,
...props
}: CopyProps,
ref
) => {
const [done, setDone] = useState(false)
const [open, setOpen] = useState(false)
const [text, setText] = useState("Copy")
const copyToClipboard = () => {
setDone(true)
copy(content)
const copyToClipboard = (
e:
| React.MouseEvent<HTMLElement, MouseEvent>
| React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
e.stopPropagation()
setTimeout(() => {
setDone(false)
}, 2000)
}
setDone(true)
copy(content)
React.useEffect(() => {
if (done) {
setText("Copied")
return
setTimeout(() => {
setDone(false)
}, 2000)
}
setTimeout(() => {
setText("Copy")
}, 500)
}, [done])
React.useEffect(() => {
if (done) {
setText("Copied")
return
}
const Component = asChild ? Slot : "button"
setTimeout(() => {
setText("Copy")
}, 500)
}, [done])
return (
<Tooltip content={text} open={done || open} onOpenChange={setOpen}>
<Component
ref={ref}
aria-label="Copy code snippet"
type="button"
className={clx("text-ui-code-icon h-fit w-fit", className)}
onClick={copyToClipboard}
{...props}
>
{children ? children : done ? <CheckCircleSolid /> : <SquareTwoStack />}
</Component>
</Tooltip>
)
})
const isDefaultVariant = (
variant?: string | null
): variant is "default" => {
return variant === "default"
}
const isDefault = isDefaultVariant(variant)
const Component = asChild ? Slot : "button"
return (
<Tooltip content={text} open={done || open} onOpenChange={setOpen}>
<Component
ref={ref}
aria-label="Copy code snippet"
type="button"
className={clx("text-ui-code-icon h-fit w-fit", className)}
onClick={copyToClipboard}
{...props}
>
{children ? (
children
) : done ? (
isDefault ? (
<CheckCircleSolid />
) : (
<CheckCircleMiniSolid />
)
) : isDefault ? (
<SquareTwoStack />
) : (
<SquareTwoStackMini />
)}
</Component>
</Tooltip>
)
}
)
Copy.displayName = "Copy"
export { Copy }
@@ -1,11 +1,28 @@
import * as React from "react"
import { clx } from "@/utils/clx"
import { VariantProps, cva } from "cva"
const statusBadgeVariants = cva({
base: "flex items-center justify-center w-5 h-[18px] [&_div]:w-2 [&_div]:h-2 [&_div]:rounded-sm",
variants: {
color: {
green: "[&_div]:bg-ui-tag-green-icon",
red: "[&_div]:bg-ui-tag-red-icon",
orange: "[&_div]:bg-ui-tag-orange-icon",
blue: "[&_div]:bg-ui-tag-blue-icon",
purple: "[&_div]:bg-ui-tag-purple-icon",
grey: "[&_div]:bg-ui-tag-neutral-icon",
},
},
defaultVariants: {
color: "grey",
},
})
interface StatusBadgeProps
extends Omit<React.ComponentPropsWithoutRef<"span">, "color"> {
color?: "green" | "red" | "blue" | "orange" | "grey" | "purple"
}
extends Omit<React.ComponentPropsWithoutRef<"span">, "color">,
VariantProps<typeof statusBadgeVariants> {}
/**
* This component is based on the span element and supports all of its props
@@ -27,31 +44,14 @@ const StatusBadge = React.forwardRef<HTMLSpanElement, StatusBadgeProps>(
<span
ref={ref}
className={clx(
"bg-ui-bg-base border-ui-border-base txt-compact-small text-ui-fg-base inline-flex items-center justify-center rounded-full border py-[3px] pl-1 pr-2.5",
"txt-compact-xsmall-plus bg-ui-bg-subtle text-ui-fg-subtle border-ui-border-base box-border flex w-fit select-none items-center overflow-hidden rounded-md border pl-0 pr-1 leading-none",
className
)}
{...props}
>
<span
role="presentation"
className="mr-0.5 flex h-5 w-5 items-center justify-center"
>
<span
role="presentation"
className="bg-ui-bg-base shadow-borders-base flex h-2.5 w-2.5 items-center justify-center rounded-full"
>
<span
className={clx("h-1.5 w-1.5 rounded-full", {
"bg-ui-tag-neutral-icon": color === "grey",
"bg-ui-tag-green-icon": color === "green",
"bg-ui-tag-red-icon": color === "red",
"bg-ui-tag-orange-icon": color === "orange",
"bg-ui-tag-blue-icon": color === "blue",
"bg-ui-tag-purple-icon": color === "purple",
})}
/>
</span>
</span>
<div role="presentation" className={statusBadgeVariants({ color })}>
<div />
</div>
{children}
</span>
)