docs: added rbac recipe (#4895)
* docs: added rbac recipe * fix eslint errors * fixed indentation causing build error * fix build warning * small fixes --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
1 parent
4d439bed38
commit
f75d9806f9
9 files changed
+1255
-258
No files matched your search
@@ -60,39 +60,39 @@ The resources in this section are used for typing, layout, and design purposes,
|
|||||||
Each of the collapsible elements below hold the path to the file that you should create, and the content of that file.
|
Each of the collapsible elements below hold the path to the file that you should create, and the content of that file.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/types/icon-type.ts
|
src/admin/types/icon-type.ts
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
```tsx title=src/admin/types/icon-type.ts
|
```tsx title=src/admin/types/icon-type.ts
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
type IconProps = {
|
type IconProps = {
|
||||||
color?: string
|
color?: string
|
||||||
size?: string | number
|
size?: string | number
|
||||||
} & React.SVGAttributes<SVGElement>
|
} & React.SVGAttributes<SVGElement>
|
||||||
|
|
||||||
export default IconProps
|
export default IconProps
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/icons/check-circle-fill-icon.tsx
|
src/admin/components/shared/icons/check-circle-fill-icon.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/icons/check-circle-fill-icon.tsx
|
```tsx title=src/admin/components/shared/icons/check-circle-fill-icon.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import IconProps from "../../../types/icon-type"
|
import IconProps from "../../../types/icon-type"
|
||||||
|
|
||||||
const CheckCircleFillIcon: React.FC<IconProps> = ({
|
const CheckCircleFillIcon: React.FC<IconProps> = ({
|
||||||
size = "24",
|
size = "24",
|
||||||
color = "currentColor",
|
color = "currentColor",
|
||||||
...attributes
|
...attributes
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
@@ -110,27 +110,27 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CheckCircleFillIcon
|
export default CheckCircleFillIcon
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/icons/cross-icon.tsx
|
src/admin/components/shared/icons/cross-icon.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/icons/cross-icon.tsx
|
```tsx title=src/admin/components/shared/icons/cross-icon.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import IconProps from "../../../types/icon-type"
|
import IconProps from "../../../types/icon-type"
|
||||||
|
|
||||||
const CrossIcon: React.FC<IconProps> = ({
|
const CrossIcon: React.FC<IconProps> = ({
|
||||||
size = "20",
|
size = "20",
|
||||||
color = "currentColor",
|
color = "currentColor",
|
||||||
...attributes
|
...attributes
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
@@ -156,25 +156,25 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CrossIcon
|
export default CrossIcon
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/icons/get-started-icon.tsx
|
src/admin/components/shared/icons/get-started-icon.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/icons/get-started-icon.tsx
|
```tsx title=src/admin/components/shared/icons/get-started-icon.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import IconProps from "../../../types/icon-type"
|
import IconProps from "../../../types/icon-type"
|
||||||
|
|
||||||
const GetStartedIcon: React.FC<IconProps> = () => (
|
const GetStartedIcon: React.FC<IconProps> = () => (
|
||||||
<svg
|
<svg
|
||||||
width="48"
|
width="48"
|
||||||
height="48"
|
height="48"
|
||||||
@@ -439,29 +439,29 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default GetStartedIcon
|
export default GetStartedIcon
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/icons/clipboard-copy-icon.tsx
|
src/admin/components/shared/icons/clipboard-copy-icon.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/icons/clipboard-copy-icon.tsx
|
```tsx title=src/admin/components/shared/icons/clipboard-copy-icon.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import IconProps from "../../../types/icon-type"
|
import IconProps from "../../../types/icon-type"
|
||||||
|
|
||||||
const ClipboardCopyIcon: React.FC<IconProps> = ({
|
const ClipboardCopyIcon: React.FC<IconProps> = ({
|
||||||
size = "20",
|
size = "20",
|
||||||
color = "currentColor",
|
color = "currentColor",
|
||||||
...attributes
|
...attributes
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
@@ -501,29 +501,29 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ClipboardCopyIcon
|
export default ClipboardCopyIcon
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/icons/computer-desktop-icon.tsx
|
src/admin/components/shared/icons/computer-desktop-icon.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/icons/computer-desktop-icon.tsx
|
```tsx title=src/admin/components/shared/icons/computer-desktop-icon.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import IconProps from "../../../types/icon-type"
|
import IconProps from "../../../types/icon-type"
|
||||||
|
|
||||||
const ComputerDesktopIcon: React.FC<IconProps> = ({
|
const ComputerDesktopIcon: React.FC<IconProps> = ({
|
||||||
size = "24",
|
size = "24",
|
||||||
color = "currentColor",
|
color = "currentColor",
|
||||||
...attributes
|
...attributes
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
@@ -541,29 +541,29 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ComputerDesktopIcon
|
export default ComputerDesktopIcon
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/icons/dollar-sign-icon.tsx
|
src/admin/components/shared/icons/dollar-sign-icon.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/icons/dollar-sign-icon.tsx
|
```tsx title=src/admin/components/shared/icons/dollar-sign-icon.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import IconProps from "../../../types/icon-type"
|
import IconProps from "../../../types/icon-type"
|
||||||
|
|
||||||
const DollarSignIcon: React.FC<IconProps> = ({
|
const DollarSignIcon: React.FC<IconProps> = ({
|
||||||
size = "24",
|
size = "24",
|
||||||
color = "currentColor",
|
color = "currentColor",
|
||||||
...attributes
|
...attributes
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
@@ -589,27 +589,27 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DollarSignIcon
|
export default DollarSignIcon
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/accordion.tsx
|
src/admin/components/shared/accordion.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/accordion.tsx
|
```tsx title=src/admin/components/shared/accordion.tsx
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import CheckCircleFillIcon from "./icons/check-circle-fill-icon"
|
import CheckCircleFillIcon from "./icons/check-circle-fill-icon"
|
||||||
|
|
||||||
type AccordionItemProps = AccordionPrimitive.AccordionItemProps & {
|
type AccordionItemProps = AccordionPrimitive.AccordionItemProps & {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -621,24 +621,24 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
complete?: boolean;
|
complete?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
triggerable?: boolean;
|
triggerable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Accordion: React.FC<
|
const Accordion: React.FC<
|
||||||
| (AccordionPrimitive.AccordionSingleProps &
|
| (AccordionPrimitive.AccordionSingleProps &
|
||||||
React.RefAttributes<HTMLDivElement>)
|
React.RefAttributes<HTMLDivElement>)
|
||||||
| (AccordionPrimitive.AccordionMultipleProps &
|
| (AccordionPrimitive.AccordionMultipleProps &
|
||||||
React.RefAttributes<HTMLDivElement>)
|
React.RefAttributes<HTMLDivElement>)
|
||||||
> & {
|
> & {
|
||||||
Item: React.FC<AccordionItemProps>;
|
Item: React.FC<AccordionItemProps>;
|
||||||
} = ({ children, ...props }) => {
|
} = ({ children, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<AccordionPrimitive.Root {...props}>
|
<AccordionPrimitive.Root {...props}>
|
||||||
{children}
|
{children}
|
||||||
</AccordionPrimitive.Root>
|
</AccordionPrimitive.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Item: React.FC<AccordionItemProps> = ({
|
const Item: React.FC<AccordionItemProps> = ({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
description,
|
description,
|
||||||
@@ -653,7 +653,7 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
active,
|
active,
|
||||||
triggerable,
|
triggerable,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const headerClass = clsx({
|
const headerClass = clsx({
|
||||||
"inter-small-semibold": headingSize === "small",
|
"inter-small-semibold": headingSize === "small",
|
||||||
"inter-base-medium": headingSize === "medium",
|
"inter-base-medium": headingSize === "medium",
|
||||||
@@ -724,11 +724,11 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
</AccordionPrimitive.Content>
|
</AccordionPrimitive.Content>
|
||||||
</AccordionPrimitive.Item>
|
</AccordionPrimitive.Item>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Accordion.Item = Item
|
Accordion.Item = Item
|
||||||
|
|
||||||
const MorphingTrigger = () => {
|
const MorphingTrigger = () => {
|
||||||
return (
|
return (
|
||||||
<div className="btn-ghost rounded-rounded group relative p-[6px]">
|
<div className="btn-ghost rounded-rounded group relative p-[6px]">
|
||||||
<div className="h-5 w-5">
|
<div className="h-5 w-5">
|
||||||
@@ -737,33 +737,33 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Accordion
|
export default Accordion
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/spinner.tsx
|
src/admin/components/shared/spinner.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/spinner.tsx
|
```tsx title=src/admin/components/shared/spinner.tsx
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
type SpinnerProps = {
|
type SpinnerProps = {
|
||||||
size?: "large" | "medium" | "small";
|
size?: "large" | "medium" | "small";
|
||||||
variant?: "primary" | "secondary";
|
variant?: "primary" | "secondary";
|
||||||
};
|
};
|
||||||
|
|
||||||
const Spinner: React.FC<SpinnerProps> = ({
|
const Spinner: React.FC<SpinnerProps> = ({
|
||||||
size = "large",
|
size = "large",
|
||||||
variant = "primary",
|
variant = "primary",
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -784,33 +784,33 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Spinner
|
export default Spinner
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/button.tsx
|
src/admin/components/shared/button.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
<!-- eslint-disable react/display-name -->
|
<!-- eslint-disable react/display-name -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/button.tsx
|
```tsx title=src/admin/components/shared/button.tsx
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import React, { Children } from "react"
|
import React, { Children } from "react"
|
||||||
import Spinner from "./spinner"
|
import Spinner from "./spinner"
|
||||||
|
|
||||||
export type ButtonProps = {
|
export type ButtonProps = {
|
||||||
variant: "primary" | "secondary" | "ghost" | "danger" | "nuclear";
|
variant: "primary" | "secondary" | "ghost" | "danger" | "nuclear";
|
||||||
size?: "small" | "medium" | "large";
|
size?: "small" | "medium" | "large";
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
variant = "primary",
|
variant = "primary",
|
||||||
@@ -868,37 +868,37 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export default Button
|
export default Button
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/code-snippets.tsx
|
src/admin/components/shared/code-snippets.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/code-snippets.tsx
|
```tsx title=src/admin/components/shared/code-snippets.tsx
|
||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import copy from "copy-to-clipboard"
|
import copy from "copy-to-clipboard"
|
||||||
import { Highlight, themes } from "prism-react-renderer"
|
import { Highlight, themes } from "prism-react-renderer"
|
||||||
import ClipboardCopyIcon from "./icons/clipboard-copy-icon"
|
import ClipboardCopyIcon from "./icons/clipboard-copy-icon"
|
||||||
import CheckCircleFillIcon from "./icons/check-circle-fill-icon"
|
import CheckCircleFillIcon from "./icons/check-circle-fill-icon"
|
||||||
|
|
||||||
const CodeSnippets = ({
|
const CodeSnippets = ({
|
||||||
snippets,
|
snippets,
|
||||||
}: {
|
}: {
|
||||||
snippets: {
|
snippets: {
|
||||||
label: string;
|
label: string;
|
||||||
language: string;
|
language: string;
|
||||||
code: string;
|
code: string;
|
||||||
}[];
|
}[];
|
||||||
}) => {
|
}) => {
|
||||||
const [active, setActive] = useState(snippets[0])
|
const [active, setActive] = useState(snippets[0])
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
@@ -971,29 +971,29 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CodeSnippets
|
export default CodeSnippets
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/container.tsx
|
src/admin/components/shared/container.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/container.tsx
|
```tsx title=src/admin/components/shared/container.tsx
|
||||||
import React, { PropsWithChildren } from "react"
|
import React, { PropsWithChildren } from "react"
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
type Props = PropsWithChildren<{
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const Container = ({ title, description, children }: Props) => {
|
export const Container = ({ title, description, children }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className="border border-grey-20 rounded-rounded bg-white py-6 px-8 flex flex-col mb-base relative">
|
<div className="border border-grey-20 rounded-rounded bg-white py-6 px-8 flex flex-col mb-base relative">
|
||||||
<div>
|
<div>
|
||||||
@@ -1009,23 +1009,23 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/badge.tsx
|
src/admin/components/shared/badge.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/badge.tsx
|
```tsx title=src/admin/components/shared/badge.tsx
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
type BadgeProps = {
|
type BadgeProps = {
|
||||||
variant:
|
variant:
|
||||||
| "primary"
|
| "primary"
|
||||||
| "danger"
|
| "danger"
|
||||||
@@ -1035,15 +1035,15 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
| "default"
|
| "default"
|
||||||
| "disabled"
|
| "disabled"
|
||||||
| "new-feature"
|
| "new-feature"
|
||||||
} & React.HTMLAttributes<HTMLDivElement>
|
} & React.HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
const Badge: React.FC<BadgeProps> = ({
|
const Badge: React.FC<BadgeProps> = ({
|
||||||
children,
|
children,
|
||||||
variant,
|
variant,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const variantClassname = clsx({
|
const variantClassname = clsx({
|
||||||
["badge-primary"]: variant === "primary",
|
["badge-primary"]: variant === "primary",
|
||||||
["badge-danger"]: variant === "danger",
|
["badge-danger"]: variant === "danger",
|
||||||
@@ -1065,26 +1065,26 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Badge
|
export default Badge
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
src/admin/components/shared/icon-badge.tsx
|
src/admin/components/shared/icon-badge.tsx
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/shared/icon-badge.tsx
|
```tsx title=src/admin/components/shared/icon-badge.tsx
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import Badge from "./badge"
|
import Badge from "./badge"
|
||||||
|
|
||||||
type IconBadgeProps = {
|
type IconBadgeProps = {
|
||||||
variant?:
|
variant?:
|
||||||
| "primary"
|
| "primary"
|
||||||
| "danger"
|
| "danger"
|
||||||
@@ -1093,14 +1093,14 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
| "ghost"
|
| "ghost"
|
||||||
| "default"
|
| "default"
|
||||||
| "disabled";
|
| "disabled";
|
||||||
} & React.HTMLAttributes<HTMLDivElement>;
|
} & React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
const IconBadge: React.FC<IconBadgeProps> = ({
|
const IconBadge: React.FC<IconBadgeProps> = ({
|
||||||
children,
|
children,
|
||||||
variant,
|
variant,
|
||||||
className,
|
className,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant={variant ?? "default"}
|
variant={variant ?? "default"}
|
||||||
@@ -1113,10 +1113,10 @@ Each of the collapsible elements below hold the path to the file that you should
|
|||||||
{children}
|
{children}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IconBadge
|
export default IconBadge
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -1798,36 +1798,36 @@ You can learn more about Admin Widgets in [this documentation](./widgets.md).
|
|||||||
In this section, you’ll create the components for each step in the onboarding flow. You’ll then update the `OnboardingFlow` widget to use these components.
|
In this section, you’ll create the components for each step in the onboarding flow. You’ll then update the `OnboardingFlow` widget to use these components.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
ProductsList component
|
ProductsList component
|
||||||
|
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
The `ProductsList` component is used in the first step of the onboarding widget. It allows the user to either open the Create Product modal or create a sample product.
|
The `ProductsList` component is used in the first step of the onboarding widget. It allows the user to either open the Create Product modal or create a sample product.
|
||||||
|
|
||||||
Create the file `src/admin/components/onboarding-flow/products/products-list.tsx` with the following content:
|
Create the file `src/admin/components/onboarding-flow/products/products-list.tsx` with the following content:
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/onboarding-flow/products/products-list.tsx
|
```tsx title=src/admin/components/onboarding-flow/products/products-list.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import Button from "../../shared/button"
|
import Button from "../../shared/button"
|
||||||
import {
|
import {
|
||||||
useAdminCreateProduct,
|
useAdminCreateProduct,
|
||||||
useAdminCreateCollection,
|
useAdminCreateCollection,
|
||||||
} from "medusa-react"
|
} from "medusa-react"
|
||||||
import {
|
import {
|
||||||
useAdminRegions,
|
useAdminRegions,
|
||||||
} from "medusa-react"
|
} from "medusa-react"
|
||||||
import {
|
import {
|
||||||
StepContentProps,
|
StepContentProps,
|
||||||
} from "../../../widgets/onboarding-flow/onboarding-flow"
|
} from "../../../widgets/onboarding-flow/onboarding-flow"
|
||||||
|
|
||||||
enum ProductStatus {
|
enum ProductStatus {
|
||||||
PUBLISHED = "published",
|
PUBLISHED = "published",
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductsList = ({ onNext, isComplete }: StepContentProps) => {
|
const ProductsList = ({ onNext, isComplete }: StepContentProps) => {
|
||||||
const { mutateAsync: createCollection, isLoading: collectionLoading } =
|
const { mutateAsync: createCollection, isLoading: collectionLoading } =
|
||||||
useAdminCreateCollection()
|
useAdminCreateCollection()
|
||||||
const { mutateAsync: createProduct, isLoading: productLoading } =
|
const { mutateAsync: createProduct, isLoading: productLoading } =
|
||||||
@@ -1929,37 +1929,37 @@ In this section, you’ll create the components for each step in the onboarding
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProductsList
|
export default ProductsList
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
ProductDetail component
|
ProductDetail component
|
||||||
|
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
The `ProductDetail` component is used in the second step of the onboarding. It shows the user a code snippet to preview the product they created in the first step.
|
The `ProductDetail` component is used in the second step of the onboarding. It shows the user a code snippet to preview the product they created in the first step.
|
||||||
|
|
||||||
Create the file `src/admin/components/onboarding-flow/products/product-detail.tsx` with the following content:
|
Create the file `src/admin/components/onboarding-flow/products/product-detail.tsx` with the following content:
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/onboarding-flow/products/product-detail.tsx
|
```tsx title=src/admin/components/onboarding-flow/products/product-detail.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import {
|
import {
|
||||||
useAdminPublishableApiKeys,
|
useAdminPublishableApiKeys,
|
||||||
} from "medusa-react"
|
} from "medusa-react"
|
||||||
import Button from "../../shared/button"
|
import Button from "../../shared/button"
|
||||||
import CodeSnippets from "../../shared/code-snippets"
|
import CodeSnippets from "../../shared/code-snippets"
|
||||||
import {
|
import {
|
||||||
StepContentProps,
|
StepContentProps,
|
||||||
} from "../../../widgets/onboarding-flow/onboarding-flow"
|
} from "../../../widgets/onboarding-flow/onboarding-flow"
|
||||||
|
|
||||||
const ProductDetail = ({ onNext, isComplete, data }: StepContentProps) => {
|
const ProductDetail = ({ onNext, isComplete, data }: StepContentProps) => {
|
||||||
const {
|
const {
|
||||||
publishable_api_keys: keys,
|
publishable_api_keys: keys,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -2015,48 +2015,48 @@ In this section, you’ll create the components for each step in the onboarding
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProductDetail
|
export default ProductDetail
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
OrdersList component
|
OrdersList component
|
||||||
|
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
The `OrdersList` component is used in the third step of the onboarding. It allows the user to create a sample order.
|
The `OrdersList` component is used in the third step of the onboarding. It allows the user to create a sample order.
|
||||||
|
|
||||||
Create the file `src/admin/components/onboarding-flow/orders/orders-list.tsx` with the following content:
|
Create the file `src/admin/components/onboarding-flow/orders/orders-list.tsx` with the following content:
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/onboarding-flow/orders/orders-list.tsx
|
```tsx title=src/admin/components/onboarding-flow/orders/orders-list.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import Button from "../../shared/button"
|
import Button from "../../shared/button"
|
||||||
import {
|
import {
|
||||||
useAdminProduct,
|
useAdminProduct,
|
||||||
} from "medusa-react"
|
} from "medusa-react"
|
||||||
import {
|
import {
|
||||||
useAdminCreateDraftOrder,
|
useAdminCreateDraftOrder,
|
||||||
} from "medusa-react"
|
} from "medusa-react"
|
||||||
import {
|
import {
|
||||||
useAdminShippingOptions,
|
useAdminShippingOptions,
|
||||||
} from "medusa-react"
|
} from "medusa-react"
|
||||||
import {
|
import {
|
||||||
useAdminRegions,
|
useAdminRegions,
|
||||||
} from "medusa-react"
|
} from "medusa-react"
|
||||||
import {
|
import {
|
||||||
useMedusa,
|
useMedusa,
|
||||||
} from "medusa-react"
|
} from "medusa-react"
|
||||||
import {
|
import {
|
||||||
StepContentProps,
|
StepContentProps,
|
||||||
} from "../../../widgets/onboarding-flow/onboarding-flow"
|
} from "../../../widgets/onboarding-flow/onboarding-flow"
|
||||||
|
|
||||||
const OrdersList = ({ onNext, isComplete, data }: StepContentProps) => {
|
const OrdersList = ({ onNext, isComplete, data }: StepContentProps) => {
|
||||||
const { product } = useAdminProduct(data.product_id)
|
const { product } = useAdminProduct(data.product_id)
|
||||||
const { mutateAsync: createDraftOrder, isLoading } =
|
const { mutateAsync: createDraftOrder, isLoading } =
|
||||||
useAdminCreateDraftOrder()
|
useAdminCreateDraftOrder()
|
||||||
@@ -2119,32 +2119,32 @@ In this section, you’ll create the components for each step in the onboarding
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OrdersList
|
export default OrdersList
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
OrderDetail component
|
OrderDetail component
|
||||||
|
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
The `OrderDetail` component is used in the fourth and final step of the onboarding. It educates the user on the next steps when developing with Medusa.
|
The `OrderDetail` component is used in the fourth and final step of the onboarding. It educates the user on the next steps when developing with Medusa.
|
||||||
|
|
||||||
Create the file `src/admin/components/onboarding-flow/orders/order-detail.tsx` with the following content:
|
Create the file `src/admin/components/onboarding-flow/orders/order-detail.tsx` with the following content:
|
||||||
|
|
||||||
<!-- eslint-disable max-len -->
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
```tsx title=src/admin/components/onboarding-flow/orders/order-detail.tsx
|
```tsx title=src/admin/components/onboarding-flow/orders/order-detail.tsx
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import IconBadge from "../../shared/icon-badge"
|
import IconBadge from "../../shared/icon-badge"
|
||||||
import ComputerDesktopIcon from "../../shared/icons/computer-desktop-icon"
|
import ComputerDesktopIcon from "../../shared/icons/computer-desktop-icon"
|
||||||
import DollarSignIcon from "../../shared/icons/dollar-sign-icon"
|
import DollarSignIcon from "../../shared/icons/dollar-sign-icon"
|
||||||
|
|
||||||
const OrderDetail = () => {
|
const OrderDetail = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
@@ -2228,10 +2228,10 @@ In this section, you’ll create the components for each step in the onboarding
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OrderDetail
|
export default OrderDetail
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
After creating the above components, import them at the top of `src/admin/widgets/onboarding-flow/onboarding-flow.tsx`:
|
After creating the above components, import them at the top of `src/admin/widgets/onboarding-flow/onboarding-flow.tsx`:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ addHowToData: true
|
|||||||
|
|
||||||
import Tabs from '@theme/Tabs';
|
import Tabs from '@theme/Tabs';
|
||||||
import TabItem from '@theme/TabItem';
|
import TabItem from '@theme/TabItem';
|
||||||
|
import Badge from '@site/src/components/Badge';
|
||||||
|
|
||||||
# How to Use Product Categories in a Storefront
|
# How to Use Product Categories in a Storefront
|
||||||
|
|
||||||
|
|||||||
@@ -63,15 +63,19 @@ Follow this guide if you want to implement subscription-based purhcases with Med
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Other Recipes
|
## Recipe: Role-Based Access Control
|
||||||
|
|
||||||
This section includes general recipes available to guide you through your development with Medusa.
|
Follow this guide if you want to implement role-based access control (RBAC) in Medusa.
|
||||||
|
|
||||||
<LearningPathList ignore={[
|
<DocCard item={{
|
||||||
"simple-quickstart",
|
type: 'link',
|
||||||
"marketplace",
|
href: '/recipes/rbac',
|
||||||
"subscriptions"
|
label: 'Build RBAC',
|
||||||
]} />
|
customProps: {
|
||||||
|
icon: Icons['credit-card-solid'],
|
||||||
|
description: 'Learn how you can implement role-based access control in Medusa.'
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|
||||||
<Feedback
|
<Feedback
|
||||||
event="survey_use_cases"
|
event="survey_use_cases"
|
||||||
@@ -81,6 +85,8 @@ This section includes general recipes available to guide you through your develo
|
|||||||
showLongForm={false}
|
showLongForm={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
<!-- vale docs.HeadingPunctuation = NO -->
|
<!-- vale docs.HeadingPunctuation = NO -->
|
||||||
|
|
||||||
## Can't find your path?
|
## Can't find your path?
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
addHowToData: true
|
||||||
|
---
|
||||||
|
|
||||||
import DocCardList from '@theme/DocCardList';
|
import DocCardList from '@theme/DocCardList';
|
||||||
import DocCard from '@theme/DocCard';
|
import DocCard from '@theme/DocCard';
|
||||||
import Icons from '@theme/Icon';
|
import Icons from '@theme/Icon';
|
||||||
@@ -352,6 +356,62 @@ Payment and fulfillment providers are associated with regions, which are not ass
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Customize Admin
|
||||||
|
|
||||||
|
As you add marketplace features to your store, you'll most likely need to customize the admin to provide an interface to manage these features.
|
||||||
|
|
||||||
|
Medusa's [admin plugin](../admin/quickstart.mdx) can be extended to add widgets or new blocks to existing pages, add UI routes to add new pages, or add setting pages.
|
||||||
|
|
||||||
|
<DocCardList colSize={4} items={[
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/admin/widgets',
|
||||||
|
label: 'Create Admin Widget',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to add widgets into existing admin pages.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/admin/routes',
|
||||||
|
label: 'Create Admin UI Routes',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to add new pages to your Medusa admin.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/admin/setting-pages',
|
||||||
|
label: 'Create Admin Setting Page',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to add new page to the Medusa admin settings.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implement Role-Based Access Control
|
||||||
|
|
||||||
|
In your marketplace, you may need to implement role-based access control (RBAC) within stores. This will restrict some users' permissions to specified functionalities or endpoints.
|
||||||
|
|
||||||
|
If you want to implement this functionality, you can follow the RBAC recipe.
|
||||||
|
|
||||||
|
<DocCard item={{
|
||||||
|
type: 'link',
|
||||||
|
href: '/recipes/rbac',
|
||||||
|
label: 'RBAC Recipe',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to implement role-based access control (RBAC) in Medusa.',
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Build a Storefront
|
## Build a Storefront
|
||||||
|
|
||||||
Medusa provides a Next.js Starter Template that you can use with Medusa. Since you've customized your Medusa project, you'll need to either customize the existing Next.js Starter Template, or create a custom storefront.
|
Medusa provides a Next.js Starter Template that you can use with Medusa. Since you've customized your Medusa project, you'll need to either customize the existing Next.js Starter Template, or create a custom storefront.
|
||||||
|
|||||||
@@ -0,0 +1,805 @@
|
|||||||
|
---
|
||||||
|
addHowToData: true
|
||||||
|
---
|
||||||
|
|
||||||
|
import DocCardList from '@theme/DocCardList';
|
||||||
|
import DocCard from '@theme/DocCard';
|
||||||
|
import Icons from '@theme/Icon';
|
||||||
|
import LearningPath from '@site/src/components/LearningPath';
|
||||||
|
|
||||||
|
# Role-Based Access Control (RBAC) Recipe
|
||||||
|
|
||||||
|
This document guides you through the different documentation resources that will help you build a marketplace with Medusa.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Role-Based Access Control (RBAC) refers to the level of access a user has. Typically, in e-commerce, you may require RBAC if you want users to only be able to perform certain actions.
|
||||||
|
|
||||||
|
For example, you may want a content-manager user who can only access CMS functionalities and another manager user who can only access order functionalities. RBAC is also useful in [marketplace use cases](./marketplace.mdx).
|
||||||
|
|
||||||
|
This recipe gives you a high-level approach to implementing RBAC in Medusa. The examples included in this recipe provide a simple implementation to give you an idea of how you can implement this functionality in your Medusa backend.
|
||||||
|
|
||||||
|
You may also follow this path that takes you through the different documentation pages that will help you to implement RBAC in Medusa.
|
||||||
|
|
||||||
|
<LearningPath pathName="rbac" />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create Role and Permission Entities
|
||||||
|
|
||||||
|
When implementing RBAC, you typically require the availability of roles and permissions. A role would include different permissions, such as the ability to access the products’ route, and it can be assigned to one or more users.
|
||||||
|
|
||||||
|
So, the first step would be to create the `Role` and `Permission` entities to represent this data. Also, since you’ll be creating relations to other entities, such as the `User` entity, you need to extend the core entities to implement these relations.
|
||||||
|
|
||||||
|
<DocCardList colSize={6} items={[
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/development/entities/create',
|
||||||
|
label: 'Create Entities',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to create an entity.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/development/entities/extend-entity',
|
||||||
|
label: 'Extend Entities',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to extend a core entity.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
Example Implementation
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
This is an example implementation of how you can create the Role and Permission entities, and extend the `User` and `Store` entities.
|
||||||
|
|
||||||
|
Creating an entity requires creating an entity class, a repository, and a migration. You can learn more [here](../development/entities/create.mdx). You’ll be creating the migration at the end of this example section.
|
||||||
|
|
||||||
|
Create the file `src/models/role.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/models/role.ts
|
||||||
|
import {
|
||||||
|
BeforeInsert,
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
} from "typeorm"
|
||||||
|
import { BaseEntity } from "@medusajs/medusa"
|
||||||
|
import { generateEntityId } from "@medusajs/medusa/dist/utils"
|
||||||
|
import { Permission } from "./permission"
|
||||||
|
import { User } from "./user"
|
||||||
|
import { Store } from "./store"
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Role extends BaseEntity {
|
||||||
|
@Column({ type: "varchar" })
|
||||||
|
name: string
|
||||||
|
|
||||||
|
// only helpful if you're integrating in a marketplace
|
||||||
|
@Index()
|
||||||
|
@Column({ nullable: true })
|
||||||
|
store_id: string
|
||||||
|
|
||||||
|
@ManyToMany(() => Permission)
|
||||||
|
@JoinTable({
|
||||||
|
name: "role_permissions",
|
||||||
|
joinColumn: {
|
||||||
|
name: "role_id",
|
||||||
|
referencedColumnName: "id",
|
||||||
|
},
|
||||||
|
inverseJoinColumn: {
|
||||||
|
name: "permission_id",
|
||||||
|
referencedColumnName: "id",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
permissions: Permission[]
|
||||||
|
|
||||||
|
@OneToMany(() => User, (user) => user.teamRole)
|
||||||
|
@JoinColumn({ name: "id", referencedColumnName: "role_id" })
|
||||||
|
users: User[]
|
||||||
|
|
||||||
|
@ManyToOne(() => Store, (store) => store.roles)
|
||||||
|
@JoinColumn({ name: "store_id" })
|
||||||
|
store: Store
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
private beforeInsert(): void {
|
||||||
|
this.id = generateEntityId(this.id, "role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the `Role` entity. You’ll see errors in your editors, which you’ll resolve by following along the example.
|
||||||
|
|
||||||
|
The `Role` entity has the following attributes:
|
||||||
|
|
||||||
|
- `id`: the ID of the role, which is available implicitly by extending `BaseEntity`
|
||||||
|
- `name`: the name of the role
|
||||||
|
- `store_id`: the ID of the store this role belongs to. This is only useful if you’re implementing RBAC in a marketplace. Otherwise, you may omit this relation.
|
||||||
|
|
||||||
|
It also has the following relations:
|
||||||
|
|
||||||
|
- `permissions`: an array of permissions included in this role.
|
||||||
|
- `store`: the Store this role belongs to.
|
||||||
|
- `users`: the users associated with this role.
|
||||||
|
|
||||||
|
Then, create the file `src/repositories/role.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/repositories/role.ts
|
||||||
|
import { Role } from "../models/role"
|
||||||
|
import {
|
||||||
|
dataSource,
|
||||||
|
} from "@medusajs/medusa/dist/loaders/database"
|
||||||
|
|
||||||
|
export const RoleRepository = dataSource
|
||||||
|
.getRepository(Role)
|
||||||
|
|
||||||
|
export default RoleRepository
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, create the file `src/models/permission.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/models/permission.ts
|
||||||
|
import {
|
||||||
|
BeforeInsert,
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
} from "typeorm"
|
||||||
|
import { BaseEntity } from "@medusajs/medusa"
|
||||||
|
import {
|
||||||
|
DbAwareColumn,
|
||||||
|
generateEntityId,
|
||||||
|
} from "@medusajs/medusa/dist/utils"
|
||||||
|
import { Role } from "./role"
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Permission extends BaseEntity {
|
||||||
|
@Column({ type: "varchar" })
|
||||||
|
name: string
|
||||||
|
|
||||||
|
// holds the permissions
|
||||||
|
@DbAwareColumn({ type: "jsonb", nullable: true })
|
||||||
|
metadata: Record<string, boolean>
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
private beforeInsert(): void {
|
||||||
|
this.id = generateEntityId(this.id, "perm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a `Permission` entity that has the following attributes:
|
||||||
|
|
||||||
|
- `id`: the ID of the permission, which is implicitly available through extending `BaseEntity`.
|
||||||
|
- `name`: the name of the permission.
|
||||||
|
- `metadata`: an object that will include the permissions. The object keys will be an admin path in the backend, and the value will be a boolean indicating whether the user has access to that path or not.
|
||||||
|
|
||||||
|
Then, create the file `src/repositories/permission.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/repositories/permission.ts
|
||||||
|
import { Permission } from "../models/permission"
|
||||||
|
import {
|
||||||
|
dataSource,
|
||||||
|
} from "@medusajs/medusa/dist/loaders/database"
|
||||||
|
|
||||||
|
export const PermissionRepository = dataSource
|
||||||
|
.getRepository(Permission)
|
||||||
|
|
||||||
|
export default PermissionRepository
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, you’ll extend the `User` and `Store` entities. As mentioned earlier, extending the `Store` entity and adding the relation is only useful if you’re implementing RBAC in a marketplace or similar use cases. So, if this doesn’t apply to you, you may skip it.
|
||||||
|
|
||||||
|
Create the file `src/models/user.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/models/user.ts
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
} from "typeorm"
|
||||||
|
import {
|
||||||
|
// alias the core entity to not cause a naming conflict
|
||||||
|
User as MedusaUser,
|
||||||
|
} from "@medusajs/medusa"
|
||||||
|
import { Role } from "./role"
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class User extends MedusaUser {
|
||||||
|
@Index()
|
||||||
|
@Column({ nullable: true })
|
||||||
|
role_id: string | null
|
||||||
|
|
||||||
|
@ManyToOne(() => Role, (role) => role.users)
|
||||||
|
@JoinColumn({ name: "role_id" })
|
||||||
|
teamRole: Role | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds a new attribute `role_id` to the core `User` entity and a `teamRole` relation that optionally associates the user with a role.
|
||||||
|
|
||||||
|
Next, create the file `src/models/store.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/models/store.ts
|
||||||
|
import { Entity, JoinColumn, OneToMany } from "typeorm"
|
||||||
|
import {
|
||||||
|
// alias the core entity to not cause a naming conflict
|
||||||
|
Store as MedusaStore,
|
||||||
|
} from "@medusajs/medusa"
|
||||||
|
import { Role } from "./role"
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Store extends MedusaStore {
|
||||||
|
@OneToMany(() => Role, (role) => role.store)
|
||||||
|
@JoinColumn({ name: "id", referencedColumnName: "store_id" })
|
||||||
|
roles: Role[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds a `roles` relation to the core `Store` entity.
|
||||||
|
|
||||||
|
Optionally, if you’re using TypeScript, create the file `src/index.d.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/index.d.ts
|
||||||
|
import { Role } from "./models/role"
|
||||||
|
|
||||||
|
export declare module "@medusajs/medusa/dist/models/user" {
|
||||||
|
|
||||||
|
declare interface User {
|
||||||
|
role_id: string | null;
|
||||||
|
teamRole: Role | null
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface Store {
|
||||||
|
roles: Role[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures that your TypeScript validation and editor autocomplete recognize the new attributes and relations you added on the core entities.
|
||||||
|
|
||||||
|
Finally, you need to create a migration to reflect these changes in the database.
|
||||||
|
|
||||||
|
You can learn about creating migrations [here](../development/entities/migrations/create.md). An example of a migration file based on the entities created above:
|
||||||
|
|
||||||
|
<!-- eslint-disable max-len -->
|
||||||
|
|
||||||
|
```ts title=src/migrations/1693225851284-AddRolesAndPermissions.ts
|
||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"
|
||||||
|
|
||||||
|
export class AddRolesAndPermissions1693225851284 implements MigrationInterface {
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD "role_id" character varying`)
|
||||||
|
await queryRunner.query(`CREATE TABLE "permission" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying NOT NULL, "metadata" jsonb, CONSTRAINT "PK_3b8b97af9d9d8807e41e6f48362" PRIMARY KEY ("id"))`)
|
||||||
|
await queryRunner.query(`CREATE TABLE "role" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying NOT NULL, "store_id" character varying, CONSTRAINT "PK_b36bcfe02fc8de3c57a8b2391c2" PRIMARY KEY ("id"))`)
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_29259dd58b1052aef9be56941d" ON "role" ("store_id") `)
|
||||||
|
await queryRunner.query(`CREATE TABLE "role_permissions" ("role_id" character varying NOT NULL, "permission_id" character varying NOT NULL, CONSTRAINT "PK_25d24010f53bb80b78e412c9656" PRIMARY KEY ("role_id", "permission_id"))`)
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_178199805b901ccd220ab7740e" ON "role_permissions" ("role_id") `)
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_17022daf3f885f7d35423e9971" ON "role_permissions" ("permission_id") `)
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "role_permissions" ADD CONSTRAINT "FK_178199805b901ccd220ab7740ec" FOREIGN KEY ("role_id") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE CASCADE`)
|
||||||
|
await queryRunner.query(`ALTER TABLE "role_permissions" ADD CONSTRAINT "FK_17022daf3f885f7d35423e9971e" FOREIGN KEY ("permission_id") REFERENCES "permission"("id") ON DELETE CASCADE ON UPDATE CASCADE`)
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_fb2e442d14add3cefbdf33c4561" FOREIGN KEY ("role_id") REFERENCES "role"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`)
|
||||||
|
await queryRunner.query(`ALTER TABLE "role" ADD CONSTRAINT "FK_29259dd58b1052aef9be56941d4" FOREIGN KEY ("store_id") REFERENCES "store"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`)
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_fb2e442d14add3cefbdf33c456" ON "user" ("role_id") `)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "role_permissions" DROP CONSTRAINT "FK_17022daf3f885f7d35423e9971e"`)
|
||||||
|
await queryRunner.query(`ALTER TABLE "role_permissions" DROP CONSTRAINT "FK_178199805b901ccd220ab7740ec"`)
|
||||||
|
await queryRunner.query(`ALTER TABLE "role" DROP CONSTRAINT "FK_29259dd58b1052aef9be56941d4"`)
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_fb2e442d14add3cefbdf33c456"`)
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_fb2e442d14add3cefbdf33c4561"`)
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "role_id"`)
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_17022daf3f885f7d35423e9971"`)
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_178199805b901ccd220ab7740e"`)
|
||||||
|
await queryRunner.query(`DROP TABLE "role_permissions"`)
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_29259dd58b1052aef9be56941d"`)
|
||||||
|
await queryRunner.query(`DROP TABLE "role"`)
|
||||||
|
await queryRunner.query(`DROP TABLE "permission"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, to reflect these changes, run the `build` command in the root directory of your medusa backend:
|
||||||
|
|
||||||
|
```bash npm2yarn
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run the migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa migrations run
|
||||||
|
```
|
||||||
|
|
||||||
|
This will reflect the entity changes in your database.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create Guard Middleware
|
||||||
|
|
||||||
|
To ensure that users who have the privilege can access an endpoint, you must create a middleware that guards admin routes. This middleware will run on all authenticated admin requests to ensure that only allowed users can access an endpoint.
|
||||||
|
|
||||||
|
Since the Medusa backend uses Express, you can create a middleware and attach it to all admin routes.
|
||||||
|
|
||||||
|
<DocCard item={{
|
||||||
|
type: 'link',
|
||||||
|
href: '/development/endpoints/add-middleware',
|
||||||
|
label: 'Create a Middleware',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to create a middleware in Medusa.',
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Example Implementation</summary>
|
||||||
|
|
||||||
|
In this example, you’ll create a middleware that runs on all admin-authenticated routes and checks the logged-in user’s permissions before giving them access to an endpoint.
|
||||||
|
|
||||||
|
Create the file `src/api/middlewares/permission.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/api/middlewares/permission.ts
|
||||||
|
import { UserService } from "@medusajs/medusa"
|
||||||
|
import { NextFunction, Request, Response } from "express"
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
if (!req.user || !req.user.userId) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// retrieve currently logged-in user
|
||||||
|
const userService = req.scope.resolve(
|
||||||
|
"userService"
|
||||||
|
) as UserService
|
||||||
|
const loggedInUser = await userService.retrieve(
|
||||||
|
req.user.userId,
|
||||||
|
{
|
||||||
|
select: ["id"],
|
||||||
|
relations: ["teamRole", "teamRole.permissions"],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!loggedInUser.teamRole) {
|
||||||
|
// considered as super user
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowed = loggedInUser.teamRole?.permissions.some(
|
||||||
|
(permission) => {
|
||||||
|
const metadataKey = Object.keys(permission.metadata).find(
|
||||||
|
(key) => key === req.path
|
||||||
|
)
|
||||||
|
if (!metadataKey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolean value
|
||||||
|
return permission.metadata[metadataKey]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isAllowed) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// deny access
|
||||||
|
res.sendStatus(401)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In this middleware, you ensure that there is a logged-in user and the logged-in user has a role. If not, the user is admitted to access the endpoint. Here, you presume that logged-in users who don’t have a role are “super-admin” users who can access all endpoints. You may choose to implement this differently.
|
||||||
|
|
||||||
|
If there’s a logged-in user that has a role, you check that the role’s permissions give them access to the current endpoint. You do that by checking if a permission’s metadata has a key with the same request’s path. It may be better here to check for matching using regular expressions, for example, to check routes with path parameters.
|
||||||
|
|
||||||
|
Otherwise, if the user’s role doesn’t provide them with enough permissions, you return a `401` response code.
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
Notice that you use `req.path` here to get the current endpoint path. However, in middlewares, this doesn’t include the mount point which is `/admin`. So, for example, if the endpoint path is `/admin/products`, `req.path` will be `/products`. You can alternatively use `req.originalUrl`. Learn more in [Express’s documentation](https://expressjs.com/en/api.html#req.originalUrl).
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
Next, to ensure that this middleware is used, import it in `src/api/index.ts` and apply it on admin routes:
|
||||||
|
|
||||||
|
```ts title=src/api/index.ts
|
||||||
|
import permissionMiddleware from "./middlewares/permission"
|
||||||
|
|
||||||
|
export default (rootDirectory: string): Router | Router[] => {
|
||||||
|
// ...
|
||||||
|
const router = Router()
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// use middleware on admin routes
|
||||||
|
router.use("/admin", permissionMiddleware)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This assumes you already have a router with all necessary CORS configurations and body parsing middlewares. If not, you can refer to the [Create Endpoint documentation](../development/endpoints/create.mdx) for more details.
|
||||||
|
|
||||||
|
Make sure to use the permission middleware after all router configurations if you want the middleware to work on your custom admin routes.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create Endpoints and Services
|
||||||
|
|
||||||
|
To manage the roles and permissions, you’ll need to create custom endpoints, typically for Create, Read, Update, and Delete (CRUD) operations.
|
||||||
|
|
||||||
|
You’ll also need to create a service for each of `Role` and `Permission` entities to perform these operations on them. The entity uses the service within its code.
|
||||||
|
|
||||||
|
Furthermore, you may need to extend core services if you need to perform actions on core entities that you’ve extended, such as the `User` entity.
|
||||||
|
|
||||||
|
<DocCardList colSize={4} items={[
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/development/endpoints/create',
|
||||||
|
label: 'Create Endpoint',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to create an endpoint in Medusa.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/development/services/create-service',
|
||||||
|
label: 'Create Service',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to create a service in Medusa',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/development/services/extend-service',
|
||||||
|
label: 'Extend Service',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to extend a core service in Medusa',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
Example Implementation
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
In this example, you’ll only implement two endpoints for simplicity: create role endpoint that create a new role with permissions, and associate user endpoint that associates a user with a role.
|
||||||
|
|
||||||
|
You’ll also create basic services for `Role` and `Permission` to perform the functionalities of each of these endpoints and extend the core `UserService` to allow associating roles with users.
|
||||||
|
|
||||||
|
Start by creating the file `src/services/permission.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/services/permission.ts
|
||||||
|
import { TransactionBaseService } from "@medusajs/medusa"
|
||||||
|
import { Permission } from "../models/permission"
|
||||||
|
import PermissionRepository from "../repositories/permission"
|
||||||
|
|
||||||
|
export type CreatePayload = Pick<
|
||||||
|
Permission,
|
||||||
|
"name" | "metadata"
|
||||||
|
>
|
||||||
|
|
||||||
|
type InjectedDependencies = {
|
||||||
|
permissionRepository: typeof PermissionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
class PermissionService extends TransactionBaseService {
|
||||||
|
protected readonly permissionRepository_:
|
||||||
|
typeof PermissionRepository
|
||||||
|
|
||||||
|
constructor(container: InjectedDependencies) {
|
||||||
|
super(container)
|
||||||
|
this.permissionRepository_ = container.permissionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreatePayload) {
|
||||||
|
// omitting validation for simplicity
|
||||||
|
return this.atomicPhase_(async (manager) => {
|
||||||
|
const permissionRepo = manager.withRepository(
|
||||||
|
this.permissionRepository_
|
||||||
|
)
|
||||||
|
const permission = permissionRepo.create(data)
|
||||||
|
|
||||||
|
const result = await permissionRepo.save(permission)
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionService
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the `PermissionService` with only a `create` method that can be used to create a permission.
|
||||||
|
|
||||||
|
Next, create the file `src/services/user.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/services/user.ts
|
||||||
|
import {
|
||||||
|
UserService as MedusaUserService, User,
|
||||||
|
} from "@medusajs/medusa"
|
||||||
|
import {
|
||||||
|
UpdateUserInput,
|
||||||
|
} from "@medusajs/medusa/dist/types/user"
|
||||||
|
|
||||||
|
class UserService extends MedusaUserService {
|
||||||
|
async update(userId: string, update: UpdateUserInput & {
|
||||||
|
role_id?: string
|
||||||
|
}): Promise<User> {
|
||||||
|
return super.update(userId, update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserService
|
||||||
|
```
|
||||||
|
|
||||||
|
This extends the core `UserService` to allow updating a user’s role. You may also want to extend the `create` method to allow specifying the role on creation.
|
||||||
|
|
||||||
|
Then, create the file `src/services/role.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/services/role.ts
|
||||||
|
import { TransactionBaseService } from "@medusajs/medusa"
|
||||||
|
import { Role } from "../models/role"
|
||||||
|
import RoleRepository from "../repositories/role"
|
||||||
|
import PermissionService, {
|
||||||
|
CreatePayload as PermissionCreatePayload,
|
||||||
|
} from "./permission"
|
||||||
|
import UserService from "./user"
|
||||||
|
|
||||||
|
type CreatePayload = Pick<Role, "name" | "store_id"> & {
|
||||||
|
permissions?: PermissionCreatePayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type InjectedDependencies = {
|
||||||
|
roleRepository: typeof RoleRepository
|
||||||
|
permissionService: PermissionService
|
||||||
|
userService: UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
class RoleService extends TransactionBaseService {
|
||||||
|
protected readonly roleRpository_: typeof RoleRepository
|
||||||
|
protected readonly permissionService_: PermissionService
|
||||||
|
protected readonly userService_: UserService
|
||||||
|
|
||||||
|
constructor(container: InjectedDependencies) {
|
||||||
|
super(container)
|
||||||
|
|
||||||
|
this.roleRpository_ = container.roleRepository
|
||||||
|
this.permissionService_ = container.permissionService
|
||||||
|
this.userService_ = container.userService
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieve(id: string): Promise<Role> {
|
||||||
|
// for simplicity, we retrieve all relations
|
||||||
|
// however, it's best to supply the relations
|
||||||
|
// as an optional method parameter
|
||||||
|
const roleRepo = this.manager_.withRepository(
|
||||||
|
this.roleRpository_
|
||||||
|
)
|
||||||
|
return await roleRepo.findOne({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
relations: [
|
||||||
|
"permissions",
|
||||||
|
"store",
|
||||||
|
"users",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreatePayload): Promise<Role> {
|
||||||
|
return this.atomicPhase_(async (manager) => {
|
||||||
|
// omitting validation for simplicity
|
||||||
|
const { permissions: permissionsData = [] } = data
|
||||||
|
delete data.permissions
|
||||||
|
|
||||||
|
const roleRepo = manager.withRepository(
|
||||||
|
this.roleRpository_
|
||||||
|
)
|
||||||
|
const role = roleRepo.create(data)
|
||||||
|
|
||||||
|
role.permissions = []
|
||||||
|
|
||||||
|
for (const permissionData of permissionsData) {
|
||||||
|
role.permissions.push(
|
||||||
|
await this.permissionService_.create(
|
||||||
|
permissionData
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const result = await roleRepo.save(role)
|
||||||
|
|
||||||
|
return await this.retrieve(result.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async associateUser(
|
||||||
|
role_id: string,
|
||||||
|
user_id: string
|
||||||
|
): Promise<Role> {
|
||||||
|
return this.atomicPhase_(async () => {
|
||||||
|
// omitting validation for simplicity
|
||||||
|
await this.userService_.update(user_id, {
|
||||||
|
role_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return await this.retrieve(role_id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleService
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the `RoleService` with three methods:
|
||||||
|
|
||||||
|
- `retrieve`: Retrieves a role with its relations.
|
||||||
|
- `create`: Creates a new role and, if provided, its permissions as well.
|
||||||
|
- `associateUser`: associates a user with a role.
|
||||||
|
|
||||||
|
Now, you can create the endpoints.
|
||||||
|
|
||||||
|
Start by creating the file `src/api/routes/admin/role/create-role.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/api/routes/admin/role/create-role.ts
|
||||||
|
import { Request, Response } from "express"
|
||||||
|
import RoleService from "../../../../services/role"
|
||||||
|
|
||||||
|
export default async (req: Request, res: Response) => {
|
||||||
|
// omitting validation for simplicity
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
store_id,
|
||||||
|
permissions = [],
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
const roleService = req.scope.resolve(
|
||||||
|
"roleService"
|
||||||
|
) as RoleService
|
||||||
|
|
||||||
|
const role = await roleService.create({
|
||||||
|
name,
|
||||||
|
store_id,
|
||||||
|
permissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(role)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the Create Role endpoint that uses the `RoleService` to create a new role. Notice that validation of received body parameters is omitted for simplicity.
|
||||||
|
|
||||||
|
Next, create the file `src/api/routes/admin/role/associate-user.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/api/routes/admin/role/associate-user.ts
|
||||||
|
import { Request, Response } from "express"
|
||||||
|
import RoleService from "../../../../services/role"
|
||||||
|
|
||||||
|
export default async (req: Request, res: Response) => {
|
||||||
|
// omitting validation for simplicity purposes
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
} = req.params
|
||||||
|
|
||||||
|
const roleService = req.scope.resolve(
|
||||||
|
"roleService"
|
||||||
|
) as RoleService
|
||||||
|
const role = await roleService.associateUser(id, user_id)
|
||||||
|
|
||||||
|
res.json(role)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the Associate User endpoint that uses the `RoleService` to associate a role with a user.
|
||||||
|
|
||||||
|
You now have to register and export these endpoints.
|
||||||
|
|
||||||
|
To do that, create the file `src/api/routes/admin/role/index.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title=src/api/routes/admin/role/index.ts
|
||||||
|
import { wrapHandler } from "@medusajs/utils"
|
||||||
|
import { Router } from "express"
|
||||||
|
import createRole from "./create-role"
|
||||||
|
import associateUser from "./associate-user"
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
export default (adminRouter: Router) => {
|
||||||
|
adminRouter.use("/roles", router)
|
||||||
|
|
||||||
|
router.post("/", wrapHandler(createRole))
|
||||||
|
router.post("/:id/user/:user_id", wrapHandler(associateUser))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds the create role endpoint under the path `/admin/roles`, and the associate user endpoint under the path `/admin/roles/:id/user/:user_id`, where `:id` is the ID of the role and `:user_id` is the ID of the user to associate with the role.
|
||||||
|
|
||||||
|
Finally, you can either export these routes in `src/api/routes/admin/index.ts` or, if the file is not available in your project, in `src/api/index.ts`:
|
||||||
|
|
||||||
|
```ts title=src/api/routes/admin/index.ts
|
||||||
|
import roleRouter from "./role"
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
export function attachAdminRoutes(adminRouter: Router) {
|
||||||
|
roleRouter(adminRouter)
|
||||||
|
// ....
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To test it out, run the `build` command in the root directory of your Medusa backend project:
|
||||||
|
|
||||||
|
```bash npm2yarn
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, start the backend with the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa develop
|
||||||
|
```
|
||||||
|
|
||||||
|
Try first to log in using the [Admin User Login endpoint](https://docs.medusajs.com/api/admin#auth_postauth) with an existing admin user. Then, send a `POST` request to the `localhost:9000/admin/roles` endpoint with the following request body parameters:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"store_id": "store_01H8XPDY8WA1Z650MZSEY4Y0V0",
|
||||||
|
"name": "Product Manager",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"name": "Allow Products",
|
||||||
|
"metadata": {
|
||||||
|
"/products": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure to replace the `store_id`'s value with your store’s ID. You can retrieve the store’s ID using the [Get Store Details endpoint](https://docs.medusajs.com/api/admin#store_getstore).
|
||||||
|
|
||||||
|
This will create a new role with a permission that allows users of this role to access the `/admin/products` endpoint. As mentioned before, because of the middleware’s implementation, you must specify the path without the `/admin` prefix. If you chose to implement this differently, such as with regular expressions, then change the permission’s metadata accordingly.
|
||||||
|
|
||||||
|
Next, create a new user using the [Create User endpoint](https://docs.medusajs.com/api/admin#users_postusers). Then, send a `POST` request to `localhost:9000/admin/roles/<role_id>/user/<user_id>`, where `<role_id>` is the ID of the role you created, and `<user_id>` is the ID of the user you created. This will associate the user with the role you created.
|
||||||
|
|
||||||
|
Finally, login with the user you created, then try to access any endpoint other than `/admin/products`. You’ll receive a `401` unauthorized response. Then, try to access the [List Products endpoint](https://docs.medusajs.com/api/admin#products_getproducts), and the user should be able to access it as expected.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Development
|
||||||
|
|
||||||
|
If your use case requires other changes or functionality implementations, check out the [Medusa Development section](../development/overview.mdx) of the documentation for all available development guides.
|
||||||
@@ -113,6 +113,44 @@ Implementing the logic depends on your use case, but you'll mainly need to do tw
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Customize Admin
|
||||||
|
|
||||||
|
As you add subscription features to your store, you'll most likely need to customize the admin to provide an interface to manage these features.
|
||||||
|
|
||||||
|
Medusa's [admin plugin](../admin/quickstart.mdx) can be extended to add widgets or new blocks to existing pages, add UI routes to add new pages, or add setting pages.
|
||||||
|
|
||||||
|
<DocCardList colSize={4} items={[
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/admin/widgets',
|
||||||
|
label: 'Create Admin Widget',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to add widgets into existing admin pages.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/admin/routes',
|
||||||
|
label: 'Create Admin UI Routes',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to add new pages to your Medusa admin.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: '/admin/setting-pages',
|
||||||
|
label: 'Create Admin Setting Page',
|
||||||
|
customProps: {
|
||||||
|
icon: Icons['academic-cap-solid'],
|
||||||
|
description: 'Learn how to add new page to the Medusa admin settings.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Build a Storefront
|
## Build a Storefront
|
||||||
|
|
||||||
Medusa provides a Next.js Starter Template that you can use with Medusa. Since you've customized your Medusa project, you'll need to either customize the existing Next.js Starter Template, or create a custom storefront.
|
Medusa provides a Next.js Starter Template that you can use with Medusa. Since you've customized your Medusa project, you'll need to either customize the existing Next.js Starter Template, or create a custom storefront.
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ module.exports = {
|
|||||||
id: "recipes/subscriptions",
|
id: "recipes/subscriptions",
|
||||||
label: "Subscriptions",
|
label: "Subscriptions",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
id: "recipes/rbac",
|
||||||
|
label: "RBAC",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ const paths: LearningPathType[] = [
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Implement Role-Based Access Control",
|
||||||
|
path: "/recipes/rbac",
|
||||||
|
description:
|
||||||
|
"In your marketplace, you may need to implement role-based access control (RBAC) within stores. This will restrict some users' permissions to specified functionalities or endpoints.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Create a storefront",
|
title: "Create a storefront",
|
||||||
path: "/starters/nextjs-medusa-starter",
|
path: "/starters/nextjs-medusa-starter",
|
||||||
@@ -193,6 +199,82 @@ const paths: LearningPathType[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "rbac",
|
||||||
|
label: "Role-based access control (RBAC)",
|
||||||
|
description: "Implement roles and permissions for admin users in Medusa",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: "Create Role and Permission Entities",
|
||||||
|
path: "/development/entities/create",
|
||||||
|
description:
|
||||||
|
"When implementing RBAC, you typically require the availability of roles and permissions, both of which would require new entities. A role would include different permissions, such as the ability to access the products’ route, and it can be assigned to one or more users.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Extend Entities",
|
||||||
|
path: "/development/entities/extend-entity",
|
||||||
|
descriptionJSX: (
|
||||||
|
<>
|
||||||
|
To associate roles with users, you need to extend the{" "}
|
||||||
|
<code>User</code> entity to add the relation between it and the new{" "}
|
||||||
|
<code>Role</code> entity. You can also extend other entities that
|
||||||
|
are associated with your custom one, such as the <code>Store</code>{" "}
|
||||||
|
entity.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Create Guard Middleware",
|
||||||
|
path: "/development/endpoints/add-middleware",
|
||||||
|
description:
|
||||||
|
"To ensure that users who have the privilege can access an endpoint, you must create a middleware that guards admin routes. This middleware will run on all authenticated admin requests to ensure that only allowed users can access an endpoint.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Create Services",
|
||||||
|
path: "/development/services/create-service",
|
||||||
|
descriptionJSX: (
|
||||||
|
<>
|
||||||
|
For every entity you create, such as the <code>Role</code> and{" "}
|
||||||
|
<code>Permission</code> entities, you must create a service that
|
||||||
|
provides create, read, update, and delete (CRUD) functionalities at
|
||||||
|
the very least.
|
||||||
|
<br />
|
||||||
|
If you also extended entities, such as the <code>User</code> entity,
|
||||||
|
you may need to{" "}
|
||||||
|
<Link to="/development/services/extend-service">
|
||||||
|
extend its core service
|
||||||
|
</Link>{" "}
|
||||||
|
<code>UserService</code> as well to perform custom functionalities
|
||||||
|
related to your implementation.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Create Endpoints",
|
||||||
|
path: "/development/endpoints/create",
|
||||||
|
descriptionJSX: (
|
||||||
|
<>
|
||||||
|
To manage the roles and permissions, you’ll need to create custom
|
||||||
|
endpoints, typically for Create, Read, Update, and Delete (CRUD)
|
||||||
|
operations.
|
||||||
|
<br />
|
||||||
|
After creating the endpoints, you may test adding roles and
|
||||||
|
permissions, and how they provide different access for different
|
||||||
|
roles and users.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
finish: {
|
||||||
|
type: "rating",
|
||||||
|
step: {
|
||||||
|
title: "Congratulations on implementing RBAC!",
|
||||||
|
description: "Please rate your experience using this recipe.",
|
||||||
|
eventName: "rating_path_rbac",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// TODO: Eventually remove these learning paths
|
||||||
{
|
{
|
||||||
name: "entity-and-api",
|
name: "entity-and-api",
|
||||||
label: "Create Entity and Expose it with Endpoints",
|
label: "Create Entity and Expose it with Endpoints",
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ module.exports = {
|
|||||||
"button-danger-pressed": "linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, rgba(255, 255, 255, 0.16) 100%)",
|
"button-danger-pressed": "linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, rgba(255, 255, 255, 0.16) 100%)",
|
||||||
"button-danger-pressed-dark": "linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, rgba(255, 255, 255, 0.14) 100%)",
|
"button-danger-pressed-dark": "linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, rgba(255, 255, 255, 0.14) 100%)",
|
||||||
"code-fade": "linear-gradient(90deg, #11182700, #111827 24px)",
|
"code-fade": "linear-gradient(90deg, #11182700, #111827 24px)",
|
||||||
"code-fade-dark": "linear-gradient(90deg, #1E1E1E00, #1E1E1E 24px)",
|
"code-fade-dark": "linear-gradient(90deg, #1B1B1F00, #1B1B1F 24px)",
|
||||||
"fade": "linear-gradient(to top, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))",
|
"fade": "linear-gradient(to top, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))",
|
||||||
"fade-dark": "linear-gradient(to top, rgba(27, 27, 31, 1), rgba(27, 27, 31, 0))",
|
"fade-dark": "linear-gradient(to top, rgba(27, 27, 31, 1), rgba(27, 27, 31, 0))",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user