fix(dashboard): Update API keys domains with new design (#7123)

**What**
- Adds new design
- Separates secret and publishable keys into two domains (re-uses the same code).
- Adds skeleton layout for loading state of details page.
- Adds toasts.
This commit is contained in:
Kasper Fabricius Kristensen
2024-04-22 22:03:28 +02:00
committed by GitHub
parent 38c971f111
commit ef29981a54
24 changed files with 480 additions and 331 deletions

View File

@@ -1168,7 +1168,6 @@
},
"apiKeyManagement": {
"domain": {
"apiKeys": "API Keys",
"publishable": "Publishable API Keys",
"secret": "Secret API Keys"
},
@@ -1186,25 +1185,40 @@
"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."
"secretKeyCreatedHint": "Your new secret key has been generated. Copy and securely store it now. This is the only time it will be displayed.",
"copySecretTokenSuccess": "Secret key was copied to clipboard.",
"copySecretTokenFailure": "Failed to copy secret key to clipboard.",
"successToast": "API key was successfully created."
},
"edit": {
"header": "Edit API Key"
"header": "Edit API Key",
"successToast": "API key {{title}} was successfully updated."
},
"tabs": {
"secret": "Secret",
"publishable": "Publishable"
"salesChannels": {
"successToast_one": "{{count}} sales channel was successfully added to the API key.",
"successToast_other": "{{count}} sales channels were successfully added to the API key.",
"alreadyAddedTooltip": "The sales channel has already been added to the API key."
},
"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."
"delete": {
"warning": "You are about to delete the API key {{title}}. This action cannot be undone.",
"successToast": "API key {{title}} was successfully deleted."
},
"revoke": {
"warning": "You are about to revoke the API key {{title}}. This action cannot be undone.",
"successToast": "API key {{title}} was successfully revoked."
},
"removeSalesChannel": {
"warning": "You are about to remove the sales channel {{name}} from the API key. This action cannot be undone.",
"warningBatch_one": "You are about to remove {{count}} sales channel from the API key. This action cannot be undone.",
"warningBatch_other": "You are about to remove {{count}} sales channels from the API key. This action cannot be undone.",
"successToast": "Sales channel was successfully removed from the API key.",
"successToastBatch_one": "{{count}} sales channel was successfully removed from the API key.",
"successToastBatch_other": "{{count}} sales channels were successfully removed from the API key."
},
"actions": {
"revoke": "Revoke"
"revoke": "Revoke API key",
"copy": "Copy API key",
"copySuccessToast": "API key was copied to clipboard."
},
"table": {
"lastUsedAtHeader": "Last Used At",

View File

@@ -136,6 +136,82 @@ export const GeneralSectionSkeleton = ({
)
}
type TableSkeletonProps = {
rowCount?: number
search?: boolean
filters?: boolean
orderBy?: boolean
pagination?: boolean
layout?: "fit" | "fill"
}
export const TableSkeleton = ({
rowCount = 10,
search = true,
filters = true,
orderBy = true,
pagination = true,
layout = "fit",
}: TableSkeletonProps) => {
// Row count + header row
const totalRowCount = rowCount + 1
const rows = Array.from({ length: totalRowCount }, (_, i) => i)
const hasToolbar = search || filters || orderBy
return (
<div
aria-hidden
className={clx({
"flex h-full flex-col overflow-hidden": layout === "fill",
})}
>
{hasToolbar && (
<div className="flex items-center justify-between px-6 py-4">
{filters && <Skeleton className="h-7 w-full max-w-[135px]" />}
{(search || orderBy) && (
<div className="flex items-center gap-x-2">
{search && <Skeleton className="h-7 w-[160px]" />}
{orderBy && <Skeleton className="h-7 w-7" />}
</div>
)}
</div>
)}
<div className="flex flex-col divide-y border-y">
{rows.map((row) => (
<Skeleton key={row} className="h-10 w-full rounded-none" />
))}
</div>
{pagination && (
<div
className={clx("flex items-center justify-between p-4", {
"border-t": layout === "fill",
})}
>
<Skeleton className="h-7 w-[138px]" />
<div className="flex items-center gap-x-2">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-11" />
<Skeleton className="h-7 w-11" />
</div>
</div>
)}
</div>
)
}
export const TableSectionSkeleton = (props: TableSkeletonProps) => {
return (
<Container className="divide-y p-0" aria-hidden>
<div className="flex items-center justify-between px-6 py-4" aria-hidden>
<HeadingSkeleton level="h2" characters={16} />
<IconButtonSkeleton />
</div>
<TableSkeleton {...props} />
</Container>
)
}
export const JsonViewSectionSkeleton = () => {
return (
<Container className="divide-y p-0" aria-hidden>

View File

@@ -68,8 +68,12 @@ const useDeveloperRoutes = (): NavItemProps[] => {
return useMemo(
() => [
{
label: t("apiKeyManagement.domain.apiKeys"),
to: "/settings/api-key-management",
label: t("apiKeyManagement.domain.publishable"),
to: "/settings/publishable-api-keys",
},
{
label: t("apiKeyManagement.domain.secret"),
to: "/settings/secret-api-keys",
},
{
label: t("workflowExecutions.domain"),

View File

@@ -1,138 +0,0 @@
import { Table, clx } from "@medusajs/ui"
import { ColumnDef } from "@tanstack/react-table"
import { Skeleton } from "../../../common/skeleton"
type DataTableSkeletonProps = {
columns: ColumnDef<any, any>[]
rowCount: number
searchable: boolean
orderBy: boolean
filterable: boolean
pagination: boolean
layout?: "fit" | "fill"
}
export const DataTableSkeleton = ({
columns,
rowCount,
filterable,
searchable,
orderBy,
pagination,
layout = "fit",
}: DataTableSkeletonProps) => {
const rows = Array.from({ length: rowCount }, (_, i) => i)
const hasToolbar = filterable || searchable || orderBy
const hasSearchOrOrder = searchable || orderBy
const hasSelect = columns.find((c) => c.id === "select")
const hasActions = columns.find((c) => c.id === "actions")
const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0)
const colWidth = 100 / colCount
return (
<div
className={clx({
"flex h-full flex-col overflow-hidden": layout === "fill",
})}
>
{hasToolbar && (
<div className="flex items-center justify-between px-6 py-4">
{filterable && <Skeleton className="h-7 w-full max-w-[160px]" />}
{hasSearchOrOrder && (
<div className="flex items-center gap-x-2">
{searchable && <Skeleton className="h-7 w-[160px]" />}
{orderBy && <Skeleton className="h-7 w-7" />}
</div>
)}
</div>
)}
<div
className={clx("flex w-full flex-col overflow-hidden", {
"flex flex-1 flex-col": layout === "fill",
})}
>
<div
className={clx("w-full", {
"min-h-0 flex-grow overflow-hidden": layout === "fill",
"overflow-x-auto": layout === "fit",
})}
>
<Table>
<Table.Header>
<Table.Row
className={clx({
"border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
hasActions,
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
hasSelect,
})}
>
{columns.map((col, i) => {
const isSelectHeader = col.id === "select"
const isActionsHeader = col.id === "actions"
const isSpecialHeader = isSelectHeader || isActionsHeader
return (
<Table.HeaderCell
key={i}
style={{
width: !isSpecialHeader ? `${colWidth}%` : undefined,
}}
>
{isActionsHeader ? null : (
<Skeleton
className={clx("h-7", {
"w-7": isSelectHeader,
"w-full": !isSelectHeader,
})}
/>
)}
</Table.HeaderCell>
)
})}
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((_, j) => (
<Table.Row key={j}>
{columns.map((col, k) => {
const isSpecialCell =
col.id === "select" || col.id === "actions"
return (
<Table.Cell key={k}>
<Skeleton
className={clx("h-7", {
"w-7": isSpecialCell,
"w-full": !isSpecialCell,
})}
/>
</Table.Cell>
)
})}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
{pagination && (
<div
className={clx("flex items-center justify-between p-4", {
"border-t": layout === "fill",
})}
>
<Skeleton className="h-7 w-[138px]" />
<div className="flex items-center gap-x-2">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-11" />
<Skeleton className="h-7 w-11" />
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -1 +0,0 @@
export * from "./data-table-skeleton"

View File

@@ -1,9 +1,9 @@
import { clx } from "@medusajs/ui"
import { memo } from "react"
import { NoRecords } from "../../common/empty-table-content"
import { TableSkeleton } from "../../common/skeleton"
import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
import { DataTableRoot, DataTableRootProps } from "./data-table-root"
import { DataTableSkeleton } from "./data-table-skeleton"
interface DataTableProps<TData>
extends Omit<DataTableRootProps<TData>, "noResults">,
@@ -35,12 +35,11 @@ export const DataTable = <TData,>({
}: DataTableProps<TData>) => {
if (isLoading) {
return (
<DataTableSkeleton
<TableSkeleton
layout={layout}
columns={columns}
rowCount={pageSize}
searchable={search}
filterable={!!filters?.length}
search={search}
filters={!!filters?.length}
orderBy={!!orderBy?.length}
pagination={!!pagination}
/>

View File

@@ -745,10 +745,10 @@ export const v2Routes: RouteObject[] = [
],
},
{
path: "api-key-management",
path: "publishable-api-keys",
element: <Outlet />,
handle: {
crumb: () => "API Key Management",
crumb: () => "Publishable API Keys",
},
children: [
{
@@ -771,22 +771,6 @@ export const v2Routes: RouteObject[] = [
},
],
},
{
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"
),
},
],
},
],
},
{
@@ -819,6 +803,58 @@ export const v2Routes: RouteObject[] = [
},
],
},
{
path: "secret-api-keys",
element: <Outlet />,
handle: {
crumb: () => "Secret API Keys",
},
children: [
{
path: "",
element: <Outlet />,
children: [
{
path: "",
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"
),
},
],
},
],
},
{
path: ":id",
lazy: () =>
import(
"../../v2-routes/api-key-management/api-key-management-detail"
),
handle: {
crumb: (data: AdminApiKeyResponse) => {
return data.api_key.title
},
},
children: [
{
path: "edit",
lazy: () =>
import(
"../../v2-routes/api-key-management/api-key-management-edit"
),
},
],
},
],
},
{
path: "taxes",
element: <Outlet />,

View File

@@ -1,7 +1,7 @@
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"
import { ApiKeyCreateForm } from "./components/api-key-create-form"
export const ApiKeyManagementCreate = () => {
const { pathname } = useLocation()
@@ -9,7 +9,7 @@ export const ApiKeyManagementCreate = () => {
return (
<RouteFocusModal>
<CreatePublishableApiKeyForm keyType={keyType} />
<ApiKeyCreateForm keyType={keyType} />
</RouteFocusModal>
)
}

View File

@@ -1,9 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Copy, Heading, Input, Prompt, Text } from "@medusajs/ui"
import { Button, Heading, Input, Prompt, Text, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Eye, EyeSlash } from "@medusajs/icons"
import { AdminApiKeyResponse } from "@medusajs/types"
import { Fragment, useState } from "react"
import { Form } from "../../../../../components/common/form"
@@ -14,29 +15,40 @@ import {
import { useCreateApiKey } from "../../../../../hooks/api/api-keys"
import { ApiKeyType } from "../../../common/constants"
const CreatePublishableApiKeySchema = zod.object({
const ApiKeyCreateSchema = zod.object({
title: zod.string().min(1),
})
type CreatePublishableApiKeyFormProps = {
type ApiKeyCreateFormProps = {
keyType: ApiKeyType
}
export const CreatePublishableApiKeyForm = ({
keyType,
}: CreatePublishableApiKeyFormProps) => {
function getRedactedKey(key?: string) {
if (!key) {
return ""
}
// Replace all characters except the first four and last two with bullets
const firstThree = key.slice(0, 4)
const lastTwo = key.slice(-2)
return `${firstThree}${"•".repeat(key.length - 6)}${lastTwo}`
}
export const ApiKeyCreateForm = ({ keyType }: ApiKeyCreateFormProps) => {
const [createdKey, setCreatedKey] = useState<
AdminApiKeyResponse["api_key"] | null
>(null)
const [showRedactedKey, setShowRedactedKey] = useState(true)
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<zod.infer<typeof CreatePublishableApiKeySchema>>({
const form = useForm<zod.infer<typeof ApiKeyCreateSchema>>({
defaultValues: {
title: "",
},
resolver: zodResolver(CreatePublishableApiKeySchema),
resolver: zodResolver(ApiKeyCreateSchema),
})
const { mutateAsync, isPending } = useCreateApiKey()
@@ -47,25 +59,51 @@ export const CreatePublishableApiKeyForm = ({
{ title: values.title, type: keyType },
{
onSuccess: ({ api_key }) => {
toast.success(t("general.success"), {
description: t("apiKeyManagement.create.successToast"),
dismissLabel: t("general.close"),
})
switch (keyType) {
case ApiKeyType.PUBLISHABLE:
handleSuccess(`/settings/api-key-management/${api_key.id}`)
handleSuccess(`/settings/publishable-api-keys/${api_key.id}`)
break
case ApiKeyType.SECRET:
setCreatedKey(api_key)
break
}
},
onError: (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
}
)
})
const handleCopyToken = () => {
if (!createdKey) {
toast.error(t("general.error"), {
dismissLabel: t("general.close"),
description: t("apiKeyManagement.create.copySecretTokenFailure"),
})
}
navigator.clipboard.writeText(createdKey?.token ?? "")
toast.success(t("general.success"), {
description: t("apiKeyManagement.create.copySecretTokenSuccess"),
dismissLabel: t("general.close"),
})
}
const handleGoToSecretKey = () => {
if (!createdKey) {
return
}
handleSuccess(`/settings/api-key-management/${createdKey.id}`)
handleSuccess(`/settings/secret-api-keys/${createdKey.id}`)
}
return (
@@ -125,7 +163,7 @@ export const CreatePublishableApiKeyForm = ({
</form>
</RouteFocusModal.Form>
<Prompt variant="confirmation" open={!!createdKey}>
<Prompt.Content className="w-fit max-w-[80%]">
<Prompt.Content className="w-fit max-w-[42.5%]">
<Prompt.Header>
<Prompt.Title>
{t("apiKeyManagement.create.secretKeyCreatedHeader")}
@@ -134,18 +172,34 @@ export const CreatePublishableApiKeyForm = ({
{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 className="flex flex-col gap-y-3 px-6 py-4">
<div className="shadow-borders-base bg-ui-bg-component grid h-8 grid-cols-[1fr_32px] items-center overflow-hidden rounded-md">
<div className="flex items-center px-2">
<Text family="mono" size="small">
{showRedactedKey
? getRedactedKey(createdKey?.token)
: createdKey?.token}
</Text>
</div>
<button
className="transition-fg hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed text-ui-fg-muted active:text-ui-fg-subtle flex size-8 appearance-none items-center justify-center border-l"
type="button"
onClick={() => setShowRedactedKey(!showRedactedKey)}
>
{showRedactedKey ? <EyeSlash /> : <Eye />}
</button>
</div>
<Button
size="small"
variant="secondary"
type="button"
className="w-full"
onClick={handleCopyToken}
>
{t("apiKeyManagement.actions.copy")}
</Button>
</div>
<Prompt.Footer>
<Prompt.Footer className="border-t py-4">
<Prompt.Action onClick={handleGoToSecretKey}>
{t("actions.continue")}
</Prompt.Action>

View File

@@ -1,5 +1,11 @@
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import {
GeneralSectionSkeleton,
JsonViewSectionSkeleton,
TableSectionSkeleton,
} from "../../../components/common/skeleton"
import { useApiKey } from "../../../hooks/api/api-keys"
import { ApiKeyType } from "../common/constants"
import { ApiKeyGeneralSection } from "./components/api-key-general-section"
@@ -18,7 +24,13 @@ export const ApiKeyManagementDetail = () => {
})
if (isLoading || !api_key) {
return <div>Loading...</div>
return (
<div className="flex flex-col gap-y-2">
<GeneralSectionSkeleton rowCount={4} />
<TableSectionSkeleton />
<JsonViewSectionSkeleton />
</div>
)
}
const isPublishable = api_key?.type === ApiKeyType.PUBLISHABLE

View File

@@ -1,12 +1,13 @@
import { PencilSquare, Trash, XCircle } from "@medusajs/icons"
import { ApiKeyDTO } from "@medusajs/types"
import {
Badge,
Container,
Copy,
Heading,
StatusBadge,
Text,
clx,
toast,
usePrompt,
} from "@medusajs/ui"
import { useTranslation } from "react-i18next"
@@ -20,7 +21,11 @@ import {
} from "../../../../../hooks/api/api-keys"
import { useUser } from "../../../../../hooks/api/users"
import { useDate } from "../../../../../hooks/use-date"
import { getApiKeyStatusProps, getApiKeyTypeProps } from "../../../common/utils"
import {
getApiKeyStatusProps,
getApiKeyTypeProps,
prettifyRedactedToken,
} from "../../../common/utils"
type ApiKeyGeneralSectionProps = {
apiKey: ApiKeyDTO
@@ -38,7 +43,7 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.warnings.delete", {
description: t("apiKeyManagement.delete.warning", {
title: apiKey.title,
}),
confirmText: t("actions.delete"),
@@ -51,15 +56,27 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
await deleteAsync(undefined, {
onSuccess: () => {
toast.success(t("general.success"), {
description: t("apiKeyManagement.delete.successToast", {
title: apiKey.title,
}),
dismissLabel: t("general.close"),
})
navigate("..", { replace: true })
},
onError: (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
})
}
const handleRevoke = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.warnings.revoke", {
description: t("apiKeyManagement.revoke.warning", {
title: apiKey.title,
}),
confirmText: t("apiKeyManagement.actions.revoke"),
@@ -70,7 +87,22 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
return
}
await revokeAsync()
await revokeAsync(undefined, {
onSuccess: () => {
toast.success(t("general.success"), {
description: t("apiKeyManagement.revoke.successToast", {
title: apiKey.title,
}),
dismissLabel: t("general.close"),
})
},
onError: (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
})
}
const dangerousActions = [
@@ -109,7 +141,7 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
{
label: t("actions.edit"),
icon: <PencilSquare />,
to: `/settings/api-key-management/${apiKey.id}/edit`,
to: "edit",
},
],
},
@@ -124,26 +156,19 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
<Text size="small" leading="compact" weight="plus">
{t("fields.key")}
</Text>
<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>
{apiKey.type !== "secret" && (
<Copy
content={apiKey.token}
variant="mini"
className="text-ui-fg-subtle"
/>
)}
</div>
{apiKey.type === "secret" ? (
<Badge size="2xsmall" className="w-fit">
{prettifyRedactedToken(apiKey.redacted)}
</Badge>
) : (
<Copy asChild content={apiKey.token}>
<Badge size="2xsmall" className="w-fit max-w-40 cursor-pointer">
<Text size="xsmall" leading="compact" className="truncate">
{prettifyRedactedToken(apiKey.redacted)}
</Text>
</Badge>
</Copy>
)}
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">

View File

@@ -1,6 +1,6 @@
import { PencilSquare, Plus, Trash } from "@medusajs/icons"
import { AdminApiKeyResponse, AdminSalesChannelResponse } from "@medusajs/types"
import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
import { Checkbox, Container, Heading, toast, usePrompt } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { useMemo, useState } from "react"
@@ -65,7 +65,7 @@ export const ApiKeySalesChannelSection = ({
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.warnings.removeSalesChannels", {
description: t("apiKeyManagement.removeSalesChannel.warningBatch", {
count: keys.length,
}),
confirmText: t("actions.continue"),
@@ -82,8 +82,23 @@ export const ApiKeySalesChannelSection = ({
},
{
onSuccess: () => {
toast.success(t("general.success"), {
description: t(
"apiKeyManagement.removeSalesChannel.successToastBatch",
{
count: keys.length,
}
),
dismissLabel: t("general.close"),
})
setRowSelection({})
},
onError: (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
}
)
}
@@ -145,7 +160,7 @@ const SalesChannelActions = ({
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.warnings.removeSalesChannel", {
description: t("apiKeyManagement.removeSalesChannel.warning", {
name: salesChannel.name,
}),
confirmText: t("actions.delete"),
@@ -156,9 +171,27 @@ const SalesChannelActions = ({
return
}
await mutateAsync({
sales_channel_ids: [salesChannel.id],
})
await mutateAsync(
{
sales_channel_ids: [salesChannel.id],
},
{
onSuccess: () => {
toast.success(t("general.success"), {
description: t("apiKeyManagement.removeSalesChannel.successToast", {
count: 1,
}),
dismissLabel: t("general.close"),
})
},
onError: (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
}
)
}
return (

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Input } from "@medusajs/ui"
import { Button, Input, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
@@ -35,9 +35,21 @@ export const EditApiKeyForm = ({ apiKey }: EditApiKeyFormProps) => {
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: () => {
onSuccess: ({ api_key }) => {
toast.success(t("general.success"), {
description: t("apiKeyManagement.edit.successToast", {
title: api_key.title,
}),
dismissLabel: t("general.close"),
})
handleSuccess()
},
onError: (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
})
})

View File

@@ -1,34 +1,14 @@
import { useTranslation } from "react-i18next"
import { Link, Outlet, useLocation } from "react-router-dom"
import { ApiKeyType } from "../common/constants"
import { Outlet, useLocation } from "react-router-dom"
import { getApiKeyTypeFromPathname } from "../common/utils"
import { ApiKeyManagementListTable } from "./components/api-key-management-list-table"
export const ApiKeyManagementList = () => {
const { pathname } = useLocation()
const { t } = useTranslation()
const keyType = pathname.includes("secret")
? ApiKeyType.SECRET
: ApiKeyType.PUBLISHABLE
const keyType = getApiKeyTypeFromPathname(pathname)
return (
<div className="flex flex-col gap-y-2">
<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

@@ -70,7 +70,7 @@ export const ApiKeyManagementListTable = ({
count={count}
pageSize={PAGE_SIZE}
orderBy={["title", "created_at", "updated_at", "revoked_at"]}
navigateTo={(row) => `/settings/api-key-management/${row.id}`}
navigateTo={(row) => row.id}
pagination
search
queryObject={raw}

View File

@@ -1,6 +1,6 @@
import { PencilSquare, Trash, XCircle } from "@medusajs/icons"
import { PencilSquare, SquareTwoStack, Trash, XCircle } from "@medusajs/icons"
import { AdminApiKeyResponse } from "@medusajs/types"
import { usePrompt } from "@medusajs/ui"
import { toast, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
@@ -22,7 +22,7 @@ export const ApiKeyRowActions = ({
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.warnings.delete", {
description: t("apiKeyManagement.delete.warning", {
title: apiKey.title,
}),
confirmText: t("actions.delete"),
@@ -33,13 +33,28 @@ export const ApiKeyRowActions = ({
return
}
await deleteAsync()
await deleteAsync(undefined, {
onSuccess: () => {
toast.success(t("general.success"), {
description: t("apiKeyManagement.delete.successToast", {
title: apiKey.title,
}),
dismissLabel: t("general.close"),
})
},
onError: (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
})
}
const handleRevoke = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.warnings.revoke", {
description: t("apiKeyManagement.revoke.warning", {
title: apiKey.title,
}),
confirmText: t("apiKeyManagement.actions.revoke"),
@@ -50,7 +65,30 @@ export const ApiKeyRowActions = ({
return
}
await revokeAsync(undefined)
await revokeAsync(undefined, {
onSuccess: () => {
toast.success(t("general.success"), {
description: t("apiKeyManagement.revoke.successToast", {
title: apiKey.title,
}),
dismissLabel: t("general.close"),
})
},
onError: (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
})
}
const handleCopyToken = () => {
navigator.clipboard.writeText(apiKey.token)
toast.success(t("general.success"), {
description: t("apiKeyManagement.actions.copySuccessToast"),
dismissLabel: t("general.close"),
})
}
return (
@@ -61,8 +99,17 @@ export const ApiKeyRowActions = ({
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/settings/api-key-management/${apiKey.id}`,
to: `${apiKey.id}/edit`,
},
...(apiKey.type !== "secret"
? [
{
label: t("apiKeyManagement.actions.copy"),
onClick: handleCopyToken,
icon: <SquareTwoStack />,
},
]
: []),
],
},
{

View File

@@ -1,5 +1,5 @@
import { AdminApiKeyResponse } from "@medusajs/types"
import { Copy, Text, clx } from "@medusajs/ui"
import { Badge, Copy, Text } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { MouseEvent, useMemo } from "react"
import { useTranslation } from "react-i18next"
@@ -7,7 +7,11 @@ 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 { TextCell } from "../../../../../components/table/table-cells/common/text-cell"
import { getApiKeyStatusProps, getApiKeyTypeProps } from "../../../common/utils"
import {
getApiKeyStatusProps,
getApiKeyTypeProps,
prettifyRedactedToken,
} from "../../../common/utils"
import { ApiKeyRowActions } from "./api-key-row-actions"
const columnHelper = createColumnHelper<AdminApiKeyResponse["api_key"]>()
@@ -31,34 +35,24 @@ export const useApiKeyManagementTableColumns = () => {
const token = getValue()
const isSecret = row.original.type === "secret"
const clickHandler = !isSecret
? (e: MouseEvent) => e.stopPropagation()
: undefined
if (isSecret) {
return <Badge size="2xsmall">{prettifyRedactedToken(token)}</Badge>
}
const clickHandler = (e: MouseEvent) => e.stopPropagation()
return (
<div
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>
{!isSecret && (
<Copy
content={row.original.token}
variant="mini"
className="text-ui-fg-subtle"
/>
)}
</div>
<Badge size="2xsmall" className="max-w-40" onClick={clickHandler}>
<Copy
content={row.original.token}
className="text-ui-fg-subtle"
asChild
>
<Text size="xsmall" leading="compact" className="truncate">
{prettifyRedactedToken(token)}
</Text>
</Copy>
</Badge>
)
},
}),

View File

@@ -3,8 +3,6 @@ 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 = () => {
@@ -12,14 +10,13 @@ export const ApiKeyManagementAddSalesChannels = () => {
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
const ready = !isLoading && api_key
if (isError) {
throw error
@@ -27,7 +24,6 @@ export const ApiKeyManagementAddSalesChannels = () => {
return (
<RouteFocusModal>
{isSecret && <ApiKeySalesChannelsFallback />}
{ready && (
<ApiKeySalesChannelsForm apiKey={id!} preSelected={preSelected} />
)}

View File

@@ -1,26 +0,0 @@
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,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { AdminSalesChannelResponse } from "@medusajs/types"
import { Button, Checkbox, Hint, Tooltip } from "@medusajs/ui"
import { Button, Checkbox, Hint, Tooltip, toast } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import {
OnChangeFn,
@@ -102,8 +102,21 @@ export const ApiKeySalesChannelsForm = ({
},
{
onSuccess: () => {
toast.success(t("general.success"), {
description: t("apiKeyManagement.salesChannels.successToast", {
count: values.sales_channel_ids.length,
}),
dismissLabel: t("general.close"),
})
handleSuccess()
},
onError: (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
}
)
})
@@ -142,6 +155,7 @@ export const ApiKeySalesChannelsForm = ({
isLoading={isLoading}
queryObject={raw}
orderBy={["name", "created_at", "updated_at"]}
layout="fill"
/>
</RouteFocusModal.Body>
</form>
@@ -191,7 +205,12 @@ const useColumns = () => {
if (isPreSelected) {
return (
<Tooltip content={t("store.currencyAlreadyAdded")} side="right">
<Tooltip
content={t(
"apiKeyManagement.salesChannels.alreadyAddedTooltip"
)}
side="right"
>
{Component}
</Tooltip>
)

View File

@@ -3,7 +3,7 @@ import { TFunction } from "i18next"
import { ApiKeyType } from "./constants"
export function getApiKeyTypeFromPathname(pathname: string) {
const isSecretKey = pathname.startsWith("/settings/api-key-management/secret")
const isSecretKey = pathname.startsWith("/settings/secret-api-keys")
switch (isSecretKey) {
case true:
@@ -46,3 +46,17 @@ export function getApiKeyTypeProps(
label: t("apiKeyManagement.type.secret"),
}
}
/**
* Returns a prettified version of the token with redacted symbols replaced with a bullet point.
* @param token - The token to prettify.
* @returns The prettified token, with redacted symbols replaced with a bullet point.
*
* @example
* ```ts
* const token = "sk_a***yx"
* const prettifiedToken = replaceRedactedSymbol(token) // "sk_a•••yx"
*/
export const prettifyRedactedToken = (token: string) => {
return token.replace("***", `•••`)
}