feat(dashboard): Secret keys domain (#7030)

* setup secret keys

* add secret keys

* fix merge
This commit is contained in:
Kasper Fabricius Kristensen
2024-04-09 15:11:32 +02:00
committed by GitHub
parent 8eb2a4156d
commit 21be6ff7ed
26 changed files with 628 additions and 336 deletions

View File

@@ -941,20 +941,54 @@
"deleteSalesChannelWarning": "You are about to delete the sales channel {{name}}. This action cannot be undone."
},
"apiKeyManagement": {
"domainTitle": "API Keys",
"domain": "{{ keyType }} API Keys",
"createKey": "Create key",
"createPublishableApiKey": "Create {{ keyType }} 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}}. 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"
"domain": {
"apiKeys": "API Keys",
"publishable": "Publishable API Keys",
"secret": "Secret API Keys"
},
"status": {
"active": "Active",
"revoked": "Revoked"
},
"type": {
"publishable": "Publishable",
"secret": "Secret"
},
"create": {
"createPublishableHeader": "Create Publishable API Key",
"createPublishableHint": "Create a new publishable API key to limit the scope of requests to specific sales channels.",
"createSecretHeader": "Create Secret API Key",
"createSecretHint": "Create a new secret API key to access the Medusa API.",
"secretKeyCreatedHeader": "Secret Key Created",
"secretKeyCreatedHint": "Your new secret key has been generated. Copy and securely store it now. This is the only time it will be displayed."
},
"edit": {
"header": "Edit API Key"
},
"tabs": {
"secret": "Secret",
"publishable": "Publishable"
},
"warnings": {
"delete": "You are about to delete the API key {{title}}. This action cannot be undone.",
"revoke": "You are about to revoke the API key {{title}}. This action cannot be undone, and the key cannot be used in future requests.",
"removeSalesChannel": "You are about to remove the sales channel {{name}} from the API key. This action cannot be undone.",
"removeSalesChannels_one": "You are about to remove {{count}} sales channel from the API key. This action cannot be undone.",
"removeSalesChannels_other": "You are about to remove {{count}} sales channels from the API key. This action cannot be undone.",
"salesChannelsSecretKey": "Sales channels cannot be added to a secret API key."
},
"actions": {
"revoke": "Revoke"
},
"table": {
"lastUsedAtHeader": "Last Used At",
"createdAtHeader": "Revoked At"
},
"fields": {
"lastUsedAtLabel": "Last used at",
"revokedByLabel": "Revoked by",
"createdByLabel": "Created by"
}
},
"returnReasons": {
"domain": "Return Reasons",

View File

@@ -64,7 +64,7 @@ const useDeveloperRoutes = (): NavItemProps[] => {
return useMemo(
() => [
{
label: t("apiKeyManagement.domainTitle"),
label: t("apiKeyManagement.domain.apiKeys"),
to: "/settings/api-key-management",
},
{

View File

@@ -193,7 +193,7 @@ export const DataTableRoot = <TData,>({
"cursor-pointer": !!to,
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
"!bg-ui-bg-disabled !hover:bg-ui-bg-disabled":
isRowDisabled,
}
)}

View File

@@ -1,12 +1,17 @@
import { Tooltip } from "@medusajs/ui"
import format from "date-fns/format"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../placeholder-cell"
type DateCellProps = {
date: Date | string
date: Date | string | null
}
export const DateCell = ({ date }: DateCellProps) => {
if (!date) {
return <PlaceholderCell />
}
const value = new Date(date)
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())

View File

@@ -2,6 +2,9 @@ import { createColumnHelper } from "@tanstack/react-table"
import { SalesChannelDTO } from "@medusajs/types"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../../components/table/table-cells/common/status-cell"
import { TextHeader } from "../../../components/table/table-cells/common/text-cell"
import {
DescriptionCell,
DescriptionHeader,
@@ -14,6 +17,8 @@ import {
const columnHelper = createColumnHelper<SalesChannelDTO>()
export const useSalesChannelTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
@@ -24,7 +29,18 @@ export const useSalesChannelTableColumns = () => {
header: () => <DescriptionHeader />,
cell: ({ getValue }) => <DescriptionCell description={getValue()} />,
}),
columnHelper.accessor("is_disabled", {
header: () => <TextHeader text={t("fields.status")} />,
cell: ({ getValue }) => {
const value = getValue()
return (
<StatusCell color={value ? "grey" : "green"}>
{value ? t("general.disabled") : t("general.enabled")}
</StatusCell>
)
},
}),
],
[]
[t]
)
}

View File

@@ -8,10 +8,10 @@ import {
} from "@medusajs/medusa"
import {
AdminApiKeyResponse,
AdminCustomerGroupResponse,
AdminProductCategoryResponse,
SalesChannelDTO,
UserDTO,
AdminCustomerGroupResponse,
} from "@medusajs/types"
import { Navigate, Outlet, RouteObject, useLocation } from "react-router-dom"
import { ErrorBoundary } from "../../components/error/error-boundary"
@@ -639,17 +639,39 @@ export const v2Routes: RouteObject[] = [
children: [
{
path: "",
lazy: () =>
import(
"../../v2-routes/api-key-management/api-key-management-list"
),
element: <Outlet />,
children: [
{
path: "create",
path: "",
lazy: () =>
import(
"../../v2-routes/api-key-management/api-key-management-create"
"../../v2-routes/api-key-management/api-key-management-list"
),
children: [
{
path: "create",
lazy: () =>
import(
"../../v2-routes/api-key-management/api-key-management-create"
),
},
],
},
{
path: "secret",
lazy: () =>
import(
"../../v2-routes/api-key-management/api-key-management-list"
),
children: [
{
path: "create",
lazy: () =>
import(
"../../v2-routes/api-key-management/api-key-management-create"
),
},
],
},
],
},
@@ -673,10 +695,10 @@ export const v2Routes: RouteObject[] = [
),
},
{
path: "add-sales-channels",
path: "sales-channels",
lazy: () =>
import(
"../../v2-routes/api-key-management/api-key-management-add-sales-channels"
"../../v2-routes/api-key-management/api-key-management-sales-channels"
),
},
],

View File

@@ -1,27 +0,0 @@
import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { useSalesChannels } from "../../../hooks/api/sales-channels"
import { AddSalesChannelsToApiKeyForm } from "./components"
export const ApiKeyManagementAddSalesChannels = () => {
const { id } = useParams()
const { sales_channels, isLoading, isError, error } = useSalesChannels({
publishable_key_id: id,
})
if (isError) {
throw error
}
return (
<RouteFocusModal>
{!isLoading && !!sales_channels && (
<AddSalesChannelsToApiKeyForm
apiKey={id!}
preSelected={sales_channels.map((sc) => sc.id)}
/>
)}
</RouteFocusModal>
)
}

View File

@@ -1 +0,0 @@
export * from "./add-sales-channels-to-api-key-form"

View File

@@ -1,10 +1,15 @@
import { useLocation } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { getApiKeyTypeFromPathname } from "../common/utils"
import { CreatePublishableApiKeyForm } from "./components/create-publishable-api-key-form"
export const ApiKeyManagementCreate = () => {
const { pathname } = useLocation()
const keyType = getApiKeyTypeFromPathname(pathname)
return (
<RouteFocusModal>
<CreatePublishableApiKeyForm />
<CreatePublishableApiKeyForm keyType={keyType} />
</RouteFocusModal>
)
}

View File

@@ -1,21 +1,34 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text } from "@medusajs/ui"
import { Button, Copy, Heading, Input, Prompt, Text } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { AdminApiKeyResponse } from "@medusajs/types"
import { Fragment, useState } from "react"
import { Form } from "../../../../../components/common/form"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreateApiKey } from "../../../../../hooks/api/api-keys"
import { ApiKeyType } from "../../../common/constants"
const CreatePublishableApiKeySchema = zod.object({
title: zod.string().min(1),
})
export const CreatePublishableApiKeyForm = () => {
type CreatePublishableApiKeyFormProps = {
keyType: ApiKeyType
}
export const CreatePublishableApiKeyForm = ({
keyType,
}: CreatePublishableApiKeyFormProps) => {
const [createdKey, setCreatedKey] = useState<
AdminApiKeyResponse["api_key"] | null
>(null)
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
@@ -30,66 +43,115 @@ export const CreatePublishableApiKeyForm = () => {
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
// @ts-ignore type is wrong compared to validation
{ title: values.title, type: "publishable" },
// @ts-ignore
{ title: values.title, type: keyType },
{
onSuccess: ({ api_key }) => {
handleSuccess(`/settings/api-key-management/${api_key.id}`)
switch (keyType) {
case ApiKeyType.PUBLISHABLE:
handleSuccess(`/settings/api-key-management/${api_key.id}`)
break
case ApiKeyType.SECRET:
setCreatedKey(api_key)
break
}
},
}
)
})
const handleGoToSecretKey = () => {
if (!createdKey) {
return
}
handleSuccess(`/settings/api-key-management/${createdKey.id}`)
}
return (
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
<Fragment>
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.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("apiKeyManagement.createPublishableApiKey")}
</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("apiKeyManagement.publishableApiKeyHint")}
</Text>
</div>
<div className="grid grid-cols-2 gap-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>
</RouteFocusModal.Header>
<RouteFocusModal.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>
{keyType === ApiKeyType.PUBLISHABLE
? t("apiKeyManagement.create.createPublishableHeader")
: t("apiKeyManagement.create.createSecretHeader")}
</Heading>
<Text size="small" className="text-ui-fg-subtle">
{keyType === ApiKeyType.PUBLISHABLE
? t("apiKeyManagement.create.createPublishableHint")
: t("apiKeyManagement.create.createSecretHint")}
</Text>
</div>
<div className="grid grid-cols-2 gap-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>
</div>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
<Prompt variant="confirmation" open={!!createdKey}>
<Prompt.Content className="w-fit max-w-[80%]">
<Prompt.Header>
<Prompt.Title>
{t("apiKeyManagement.create.secretKeyCreatedHeader")}
</Prompt.Title>
<Prompt.Description>
{t("apiKeyManagement.create.secretKeyCreatedHint")}
</Prompt.Description>
</Prompt.Header>
<div className="px-6 pt-6">
<div className="shadow-borders-base bg-ui-bg-component flex items-center gap-x-2 rounded-md px-4 py-2.5">
<Text family="mono" size="small">
{createdKey?.token}
</Text>
<Copy
className="text-ui-fg-subtle"
content={createdKey?.token!}
/>
</div>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
<Prompt.Footer>
<Prompt.Action onClick={handleGoToSecretKey}>
{t("actions.continue")}
</Prompt.Action>
</Prompt.Footer>
</Prompt.Content>
</Prompt>
</Fragment>
)
}

View File

@@ -1,9 +1,10 @@
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { useApiKey } from "../../../hooks/api/api-keys"
import { ApiKeyType } from "../common/constants"
import { ApiKeyGeneralSection } from "./components/api-key-general-section"
import { apiKeyLoader } from "./loader"
import { ApiKeySalesChannelSection } from "./components/api-key-sales-channel-section"
import { apiKeyLoader } from "./loader"
export const ApiKeyManagementDetail = () => {
const initialData = useLoaderData() as Awaited<
@@ -11,6 +12,7 @@ export const ApiKeyManagementDetail = () => {
>
const { id } = useParams()
const { api_key, isLoading, isError, error } = useApiKey(id!, undefined, {
initialData: initialData,
})
@@ -19,6 +21,8 @@ export const ApiKeyManagementDetail = () => {
return <div>Loading...</div>
}
const isPublishable = api_key?.type === ApiKeyType.PUBLISHABLE
if (isError) {
throw error
}
@@ -26,7 +30,7 @@ export const ApiKeyManagementDetail = () => {
return (
<div className="flex flex-col gap-y-2">
<ApiKeyGeneralSection apiKey={api_key} />
<ApiKeySalesChannelSection apiKey={api_key} />
{isPublishable && <ApiKeySalesChannelSection apiKey={api_key} />}
<JsonViewSection data={api_key} />
<Outlet />
</div>

View File

@@ -6,6 +6,7 @@ import {
Heading,
StatusBadge,
Text,
clx,
usePrompt,
} from "@medusajs/ui"
import { useTranslation } from "react-i18next"
@@ -18,6 +19,8 @@ import {
useRevokeApiKey,
} from "../../../../../hooks/api/api-keys"
import { useUser } from "../../../../../hooks/api/users"
import { useDate } from "../../../../../hooks/use-date"
import { getApiKeyStatusProps, getApiKeyTypeProps } from "../../../common/utils"
type ApiKeyGeneralSectionProps = {
apiKey: ApiKeyDTO
@@ -27,6 +30,7 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const prompt = usePrompt()
const { getFullDate } = useDate()
const { mutateAsync: revokeAsync } = useRevokeApiKey(apiKey.id)
const { mutateAsync: deleteAsync } = useDeleteApiKey(apiKey.id)
@@ -34,7 +38,7 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.deleteKeyWarning", {
description: t("apiKeyManagement.warnings.delete", {
title: apiKey.title,
}),
confirmText: t("actions.delete"),
@@ -55,10 +59,10 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
const handleRevoke = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.revokeKeyWarning", {
description: t("apiKeyManagement.warnings.revoke", {
title: apiKey.title,
}),
confirmText: t("apiKeyManagement.revoke"),
confirmText: t("apiKeyManagement.actions.revoke"),
cancelText: t("actions.cancel"),
})
@@ -80,19 +84,24 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
if (!apiKey.revoked_at) {
dangerousActions.unshift({
icon: <XCircle />,
label: t("apiKeyManagement.revoke"),
label: t("apiKeyManagement.actions.revoke"),
onClick: handleRevoke,
})
}
const apiKeyStatus = getApiKeyStatusProps(apiKey.revoked_at, t)
const apiKeyType = getApiKeyTypeProps(apiKey.type, t)
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<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>
<div className="flex items-center gap-x-4">
<div className="flex items-center gap-x-2">
<StatusBadge color={apiKeyStatus.color}>
{apiKeyStatus.label}
</StatusBadge>
</div>
<ActionMenu
groups={[
{
@@ -111,31 +120,59 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
/>
</div>
</div>
<div className="grid grid-cols-2 items-center px-6 py-4">
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.key")}
</Text>
<div className="bg-ui-bg-subtle border-ui-border-base box-border flex w-fit cursor-default items-center gap-x-0.5 overflow-hidden rounded-full border pl-2 pr-1">
<div
className={clx(
"bg-ui-bg-subtle border-ui-border-base box-border flex w-fit cursor-default items-center gap-x-0.5 overflow-hidden rounded-full border pl-2 pr-1",
{
"pr-2": apiKey.type === "secret",
"cursor-pointer": apiKey.type !== "secret",
}
)}
>
<Text size="xsmall" leading="compact" className="truncate">
{apiKey.redacted}
</Text>
<Copy
content={apiKey.token}
variant="mini"
className="text-ui-fg-subtle"
/>
{apiKey.type !== "secret" && (
<Copy
content={apiKey.token}
variant="mini"
className="text-ui-fg-subtle"
/>
)}
</div>
</div>
<div className="grid grid-cols-2 items-center px-6 py-4">
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("apiKeyManagement.createdBy")}
{t("fields.type")}
</Text>
<Text size="small" leading="compact">
{apiKeyType.label}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("apiKeyManagement.fields.lastUsedAtLabel")}
</Text>
<Text size="small" leading="compact">
{apiKey.last_used_at
? getFullDate({ date: apiKey.last_used_at, includeTime: true })
: "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("apiKeyManagement.fields.createdByLabel")}
</Text>
<ActionBy userId={apiKey.created_by} />
</div>
{apiKey.revoked_at && (
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("apiKeyManagement.revokedBy")}
{t("apiKeyManagement.fields.revokedByLabel")}
</Text>
<ActionBy userId={apiKey.revoked_by} />
</div>

View File

@@ -1,29 +1,18 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import {
Button,
Checkbox,
Container,
Heading,
StatusBadge,
usePrompt,
} from "@medusajs/ui"
import { PencilSquare, Plus, Trash } from "@medusajs/icons"
import { AdminApiKeyResponse, AdminSalesChannelResponse } from "@medusajs/types"
import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import {
adminPublishableApiKeysKeys,
useAdminCustomPost,
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 { DataTable } from "../../../../../components/table/data-table"
import { useBatchRemoveSalesChannelsFromApiKey } from "../../../../../hooks/api/api-keys"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/use-sales-channel-table-columns"
import { useSalesChannelTableFilters } from "../../../../../hooks/table/filters/use-sales-channel-table-filters"
import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { DataTable } from "../../../../../components/table/data-table"
import { keepPreviousData } from "@tanstack/react-query"
import { AdminApiKeyResponse, AdminSalesChannelResponse } from "@medusajs/types"
import { useBatchRemoveSalesChannelsFromApiKey } from "../../../../../hooks/api/api-keys"
type ApiKeySalesChannelSectionProps = {
apiKey: AdminApiKeyResponse["api_key"]
@@ -49,8 +38,8 @@ export const ApiKeySalesChannelSection = ({
}
)
const columns = useColumns({ apiKey: apiKey.id })
// const filters = useProductTableFilters(["sales_channel_id"])
const columns = useColumns()
const filters = useSalesChannelTableFilters()
const { table } = useDataTable({
data: sales_channels ?? [],
@@ -64,6 +53,9 @@ export const ApiKeySalesChannelSection = ({
state: rowSelection,
updater: setRowSelection,
},
meta: {
apiKey: apiKey.id,
},
})
const { mutateAsync } = useBatchRemoveSalesChannelsFromApiKey(apiKey.id)
@@ -73,7 +65,7 @@ export const ApiKeySalesChannelSection = ({
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.removeSalesChannelsWarning", {
description: t("apiKeyManagement.warnings.removeSalesChannels", {
count: keys.length,
}),
confirmText: t("actions.continue"),
@@ -100,19 +92,28 @@ export const ApiKeySalesChannelSection = ({
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("salesChannels.domain")}</Heading>
<Button variant="secondary" size="small" asChild>
<Link to="add-sales-channels">{t("general.add")}</Link>
</Button>
<ActionMenu
groups={[
{
actions: [
{
icon: <Plus />,
label: t("actions.add"),
to: "sales-channels",
},
],
},
]}
/>
</div>
<DataTable
table={table}
columns={columns}
filters={filters}
count={count}
pageSize={PAGE_SIZE}
pagination
search
isLoading={isLoading}
queryObject={raw}
navigateTo={(row) => `/settings/sales-channels/${row.id}`}
orderBy={["name", "created_at", "updated_at"]}
commands={[
{
@@ -121,6 +122,9 @@ export const ApiKeySalesChannelSection = ({
shortcut: "r",
},
]}
pageSize={PAGE_SIZE}
pagination
search
/>
</Container>
)
@@ -141,7 +145,7 @@ const SalesChannelActions = ({
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.removeSalesChannelWarning", {
description: t("apiKeyManagement.warnings.removeSalesChannel", {
name: salesChannel.name,
}),
confirmText: t("actions.delete"),
@@ -186,8 +190,8 @@ const SalesChannelActions = ({
const columnHelper =
createColumnHelper<AdminSalesChannelResponse["sales_channel"]>()
const useColumns = ({ apiKey }: { apiKey: string }) => {
const { t } = useTranslation()
const useColumns = () => {
const base = useSalesChannelTableColumns()
return useMemo(
() => [
@@ -219,36 +223,20 @@ const useColumns = ({ apiKey }: { apiKey: string }) => {
)
},
}),
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>
)
},
}),
...base,
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
const { apiKey } = table.options.meta as {
apiKey: string
}
return (
<SalesChannelActions salesChannel={row.original} apiKey={apiKey} />
)
},
}),
],
[t]
[base]
)
}

View File

@@ -18,7 +18,7 @@ export const ApiKeyManagementEdit = () => {
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("apiKeyManagement.editKey")}</Heading>
<Heading>{t("apiKeyManagement.edit.header")}</Heading>
</RouteDrawer.Header>
{!isLoading && !!api_key && <EditApiKeyForm apiKey={api_key} />}
</RouteDrawer>

View File

@@ -1,13 +1,35 @@
import { Outlet } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { Link, Outlet, useLocation } from "react-router-dom"
import { ApiKeyType } from "../common/constants"
import { ApiKeyManagementListTable } from "./components/api-key-management-list-table"
// TODO: Add secret API keys
export const ApiKeyManagementList = () => {
const { pathname } = useLocation()
const { t } = useTranslation()
const keyType = pathname.includes("secret")
? ApiKeyType.SECRET
: ApiKeyType.PUBLISHABLE
return (
<div className="flex flex-col gap-y-2">
<ApiKeyManagementListTable keyType="publishable" />
{/* <ApiKeyManagementListTable keyType="secret" /> */}
<div className="flex items-center">
<Link
to="/settings/api-key-management"
data-state={keyType === ApiKeyType.PUBLISHABLE ? "active" : ""}
className="txt-compact-small-plus transition-fg text-ui-fg-subtle hover:text-ui-fg-base focus-visible:border-ui-border-interactive focus-visible:!shadow-borders-focus focus-visible:bg-ui-bg-base data-[state=active]:text-ui-fg-base data-[state=active]:bg-ui-bg-base data-[state=active]:shadow-elevation-card-rest inline-flex items-center justify-center rounded-full border border-transparent bg-transparent px-2.5 py-1 outline-none"
>
{t("apiKeyManagement.tabs.publishable")}
</Link>
<Link
to="/settings/api-key-management/secret"
data-state={keyType === ApiKeyType.SECRET ? "active" : ""}
className="txt-compact-small-plus transition-fg text-ui-fg-subtle hover:text-ui-fg-base focus-visible:border-ui-border-interactive focus-visible:!shadow-borders-focus focus-visible:bg-ui-bg-base data-[state=active]:text-ui-fg-base data-[state=active]:bg-ui-bg-base data-[state=active]:shadow-elevation-card-rest inline-flex items-center justify-center rounded-full border border-transparent bg-transparent px-2.5 py-1 outline-none"
>
{t("apiKeyManagement.tabs.secret")}
</Link>
</div>
<ApiKeyManagementListTable keyType={keyType} />
<Outlet />
</div>
)

View File

@@ -5,7 +5,6 @@ import { Link } from "react-router-dom"
import { DataTable } from "../../../../../components/table/data-table"
import { useApiKeys } from "../../../../../hooks/api/api-keys"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { upperCaseFirst } from "../../../../../lib/uppercase-first"
import { useApiKeyManagementTableColumns } from "./use-api-key-management-table-columns"
import { useApiKeyManagementTableFilters } from "./use-api-key-management-table-filters"
import { useApiKeyManagementTableQuery } from "./use-api-key-management-table-query"
@@ -54,7 +53,9 @@ export const ApiKeyManagementListTable = ({
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">
{t(`apiKeyManagement.domain`, { keyType: upperCaseFirst(keyType) })}
{keyType === "publishable"
? t(`apiKeyManagement.domain.publishable`)
: t("apiKeyManagement.domain.secret")}
</Heading>
<Link to="create">
<Button variant="secondary" size="small">

View File

@@ -0,0 +1,85 @@
import { PencilSquare, Trash, XCircle } from "@medusajs/icons"
import { AdminApiKeyResponse } from "@medusajs/types"
import { usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
useDeleteApiKey,
useRevokeApiKey,
} from "../../../../../hooks/api/api-keys"
export const ApiKeyRowActions = ({
apiKey,
}: {
apiKey: AdminApiKeyResponse["api_key"]
}) => {
const { mutateAsync: revokeAsync } = useRevokeApiKey(apiKey.id)
const { mutateAsync: deleteAsync } = useDeleteApiKey(apiKey.id)
const { t } = useTranslation()
const prompt = usePrompt()
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.warnings.delete", {
title: apiKey.title,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await deleteAsync()
}
const handleRevoke = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.warnings.revoke", {
title: apiKey.title,
}),
confirmText: t("apiKeyManagement.actions.revoke"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await revokeAsync(undefined)
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/settings/api-key-management/${apiKey.id}`,
},
],
},
{
actions: [
{
icon: <XCircle />,
label: t("apiKeyManagement.actions.revoke"),
onClick: handleRevoke,
},
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -1,22 +1,14 @@
import { PencilSquare, Trash, XCircle } from "@medusajs/icons"
import { PublishableApiKey } from "@medusajs/medusa"
import { Copy, Text, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import {
adminPublishableApiKeysKeys,
useAdminCustomDelete,
useAdminCustomPost,
} from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { AdminApiKeyResponse } from "@medusajs/types"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Copy, Text, clx } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { MouseEvent, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { DateCell } from "../../../../../components/table/table-cells/common/date-cell"
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
import {
useDeleteApiKey,
useRevokeApiKey,
} from "../../../../../hooks/api/api-keys"
import { TextCell } from "../../../../../components/table/table-cells/common/text-cell"
import { getApiKeyStatusProps, getApiKeyTypeProps } from "../../../common/utils"
import { ApiKeyRowActions } from "./api-key-row-actions"
const columnHelper = createColumnHelper<AdminApiKeyResponse["api_key"]>()
@@ -37,34 +29,61 @@ export const useApiKeyManagementTableColumns = () => {
header: "Token",
cell: ({ getValue, row }) => {
const token = getValue()
const isSecret = row.original.type === "secret"
const clickHandler = !isSecret
? (e: MouseEvent) => e.stopPropagation()
: undefined
return (
<div
className="bg-ui-bg-subtle border-ui-border-base box-border flex w-fit max-w-[220px] cursor-default items-center gap-x-0.5 overflow-hidden rounded-full border pl-2 pr-1"
onClick={(e) => e.stopPropagation()}
className={clx(
"bg-ui-bg-subtle border-ui-border-base box-border flex w-fit max-w-[220px] items-center gap-x-0.5 overflow-hidden rounded-full border pl-2",
{
"cursor-default pr-1": !isSecret,
},
{
"pr-2": isSecret,
}
)}
onClick={clickHandler}
>
<Text size="xsmall" leading="compact" className="truncate">
{token}
</Text>
<Copy
content={row.original.token}
variant="mini"
className="text-ui-fg-subtle"
/>
{!isSecret && (
<Copy
content={row.original.token}
variant="mini"
className="text-ui-fg-subtle"
/>
)}
</div>
)
},
}),
columnHelper.accessor("type", {
header: t("fields.type"),
cell: ({ getValue }) => {
const { label } = getApiKeyTypeProps(getValue(), t)
return <TextCell text={label} />
},
}),
columnHelper.accessor("revoked_at", {
header: t("fields.status"),
cell: ({ getValue }) => {
const revokedAt = getValue()
const { color, label } = getApiKeyStatusProps(getValue(), t)
return (
<StatusCell color={revokedAt ? "red" : "green"}>
{revokedAt ? t("general.revoked") : t("general.active")}
</StatusCell>
)
return <StatusCell color={color}>{label}</StatusCell>
},
}),
columnHelper.accessor("last_used_at", {
header: t("apiKeyManagement.table.lastUsedAtHeader"),
cell: ({ getValue }) => {
const date = getValue()
return <DateCell date={date} />
},
}),
columnHelper.accessor("created_at", {
@@ -78,82 +97,10 @@ export const useApiKeyManagementTableColumns = () => {
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <ApiKeyActions apiKey={row.original as any} />
return <ApiKeyRowActions apiKey={row.original as any} />
},
}),
],
[t]
)
}
export const ApiKeyActions = ({ apiKey }: { apiKey: PublishableApiKey }) => {
const { mutateAsync: revokeAsync } = useRevokeApiKey(apiKey.id)
const { mutateAsync: deleteAsync } = useDeleteApiKey(apiKey.id)
const { t } = useTranslation()
const prompt = usePrompt()
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.deleteKeyWarning", {
title: apiKey.title,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.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("actions.cancel"),
})
if (!res) {
return
}
await revokeAsync(undefined)
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/settings/api-key-management/${apiKey.id}`,
},
],
},
{
actions: [
{
icon: <XCircle />,
label: t("apiKeyManagement.revoke"),
onClick: handleRevoke,
},
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,36 @@
import { useParams } from "react-router-dom"
import { AdminApiKeyResponse, AdminSalesChannelResponse } from "@medusajs/types"
import { RouteFocusModal } from "../../../components/route-modal"
import { useApiKey } from "../../../hooks/api/api-keys"
import { ApiKeyType } from "../common/constants"
import { ApiKeySalesChannelsFallback } from "./components/api-key-sales-channels-fallback"
import { ApiKeySalesChannelsForm } from "./components/api-key-sales-channels-form"
export const ApiKeyManagementAddSalesChannels = () => {
const { id } = useParams()
const { api_key, isLoading, isError, error } = useApiKey(id!)
const isSecret = api_key?.type === ApiKeyType.SECRET
const preSelected = (
api_key as AdminApiKeyResponse["api_key"] & {
sales_channels: AdminSalesChannelResponse["sales_channel"][] | null
}
)?.sales_channels?.map((sc) => sc.id)
const ready = !isLoading && api_key && !isSecret
if (isError) {
throw error
}
return (
<RouteFocusModal>
{isSecret && <ApiKeySalesChannelsFallback />}
{ready && (
<ApiKeySalesChannelsForm apiKey={id!} preSelected={preSelected} />
)}
</RouteFocusModal>
)
}

View File

@@ -0,0 +1,26 @@
import { Alert, Button } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { RouteFocusModal } from "../../../../../components/route-modal"
export const ApiKeySalesChannelsFallback = () => {
const { t } = useTranslation()
return (
<div className="flex size-full flex-col">
<RouteFocusModal.Header>
<div className="flex items-center justify-end">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex flex-1 items-center justify-center">
<Alert variant="warning">
{t("apiKeyManagement.warnings.salesChannelsSecretKey")}
</Alert>
</RouteFocusModal.Body>
</div>
)
}

View File

@@ -1,5 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Checkbox, Hint, StatusBadge, Tooltip } from "@medusajs/ui"
import { AdminSalesChannelResponse } from "@medusajs/types"
import { Button, Checkbox, Hint, Tooltip } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import {
OnChangeFn,
RowSelectionState,
@@ -12,30 +14,29 @@ import * as zod from "zod"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../components/route-modal"
import { useBatchAddSalesChannelsToApiKey } from "../../../../hooks/api/api-keys"
import { useSalesChannelTableQuery } from "../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../hooks/use-data-table"
import { useSalesChannels } from "../../../../hooks/api/sales-channels"
import { keepPreviousData } from "@tanstack/react-query"
import { DataTable } from "../../../../components/table/data-table"
import { AdminSalesChannelResponse } from "@medusajs/types"
} from "../../../../../components/route-modal"
import { DataTable } from "../../../../../components/table/data-table"
import { useBatchAddSalesChannelsToApiKey } from "../../../../../hooks/api/api-keys"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/use-sales-channel-table-columns"
import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
type AddSalesChannelsToApiKeyFormProps = {
type ApiKeySalesChannelFormProps = {
apiKey: string
preSelected: string[]
preSelected?: string[]
}
const AddSalesChannelsToApiKeySchema = zod.object({
sales_channel_ids: zod.array(zod.string()).min(1),
sales_channel_ids: zod.array(zod.string()),
})
const PAGE_SIZE = 50
export const AddSalesChannelsToApiKeyForm = ({
export const ApiKeySalesChannelsForm = ({
apiKey,
preSelected,
}: AddSalesChannelsToApiKeyFormProps) => {
preSelected = [],
}: ApiKeySalesChannelFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
@@ -50,9 +51,7 @@ export const AddSalesChannelsToApiKeyForm = ({
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
// @ts-ignore
const { mutateAsync, isLoading: isMutating } =
useBatchAddSalesChannelsToApiKey(apiKey)
const { mutateAsync, isPending } = useBatchAddSalesChannelsToApiKey(apiKey)
const { raw, searchParams } = useSalesChannelTableQuery({
pageSize: PAGE_SIZE,
@@ -127,7 +126,7 @@ export const AddSalesChannelsToApiKeyForm = ({
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isMutating}>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
@@ -155,6 +154,7 @@ const columnHelper =
const useColumns = () => {
const { t } = useTranslation()
const base = useSalesChannelTableColumns()
return useMemo(
() => [
@@ -200,32 +200,8 @@ const useColumns = () => {
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>
)
},
}),
...base,
],
[t]
[t, base]
)
}

View File

@@ -1 +1 @@
export { ApiKeyManagementAddSalesChannels as Component } from "./api-key-management-add-sales-channels"
export { ApiKeyManagementAddSalesChannels as Component } from "./api-key-management-sales-channels"

View File

@@ -0,0 +1,4 @@
export enum ApiKeyType {
SECRET = "secret",
PUBLISHABLE = "publishable",
}

View File

@@ -0,0 +1,48 @@
import { AdminApiKeyResponse } from "@medusajs/types"
import { TFunction } from "i18next"
import { ApiKeyType } from "./constants"
export function getApiKeyTypeFromPathname(pathname: string) {
const isSecretKey = pathname.startsWith("/settings/api-key-management/secret")
switch (isSecretKey) {
case true:
return ApiKeyType.SECRET
case false:
return ApiKeyType.PUBLISHABLE
}
}
export function getApiKeyStatusProps(
revokedAt: Date | string | null,
t: TFunction
): { color: "red" | "green"; label: string } {
if (!revokedAt) {
return {
color: "green",
label: t("apiKeyManagement.status.active"),
}
}
return {
color: "red",
label: t("apiKeyManagement.status.revoked"),
}
}
export function getApiKeyTypeProps(
type: AdminApiKeyResponse["api_key"]["type"],
t: TFunction
): { color: "green" | "blue"; label: string } {
if (type === ApiKeyType.PUBLISHABLE) {
return {
color: "green",
label: t("apiKeyManagement.type.publishable"),
}
}
return {
color: "blue",
label: t("apiKeyManagement.type.secret"),
}
}