feat: implement CRUD UI for IDP organization sync settings (#15503)

resolves coder/internal#205

The goal is to create a new page located in deployment settings to allow
users to create and update organization IDP sync settings.

- [x] Use shadcn button for export policy button
- [x] Disable save button if form is not dirty
- [x] Disable "Add IdP organization" button if idp org name or coder
orgs are empty
- [x] Add footnote label below organization sync field input
- [x] Add button to Delete rows in mapping table
- [x] Create Multi-select combox box component to select coder org to
map to idp org
- [x] Storybook tests
- [x] Tooltip for assign default org switch
- [x] Display success/error toast on form submission

<img width="1181" alt="Screenshot 2024-12-02 at 20 33 07"
src="https://github.com/user-attachments/assets/86877d3b-82ec-4ed2-b239-bf8497352a9e">
This commit is contained in:
Jaayden Halko
2024-12-04 14:23:18 -05:00
committed by GitHub
parent 3b1131ca01
commit 6c9ccca687
39 changed files with 3188 additions and 75 deletions
+2 -1
View File
@@ -16,5 +16,6 @@
"ui": "/components/ui",
"lib": "/lib",
"hooks": "/hooks"
}
},
"iconLibrary": "lucide"
}
+11 -1
View File
@@ -50,7 +50,12 @@
"@mui/system": "5.16.7",
"@mui/utils": "5.16.6",
"@mui/x-tree-view": "7.18.0",
"@radix-ui/react-dialog": "1.1.2",
"@radix-ui/react-label": "2.1.0",
"@radix-ui/react-slider": "1.2.1",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-switch": "1.1.1",
"@radix-ui/react-visually-hidden": "1.1.0",
"@tanstack/react-query-devtools": "4.35.3",
"@xterm/addon-canvas": "0.7.0",
"@xterm/addon-fit": "0.10.0",
@@ -67,6 +72,7 @@
"chroma-js": "2.4.2",
"class-variance-authority": "0.7.0",
"clsx": "2.1.1",
"cmdk": "1.0.0",
"color-convert": "2.0.1",
"cron-parser": "4.9.0",
"cronstrue": "2.50.0",
@@ -176,7 +182,11 @@
"vite-plugin-checker": "0.8.0",
"vite-plugin-turbosnap": "1.0.3"
},
"browserslist": ["chrome 110", "firefox 111", "safari 16.0"],
"browserslist": [
"chrome 110",
"firefox 111",
"safari 16.0"
],
"resolutions": {
"optionator": "0.9.3",
"semver": "7.6.2"
+942
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -712,6 +712,24 @@ class ApiMethods {
return response.data;
};
getOrganizationIdpSyncSettings =
async (): Promise<TypesGen.OrganizationSyncSettings> => {
const response = await this.axios.get<TypesGen.OrganizationSyncSettings>(
"/api/v2/settings/idpsync/organization",
);
return response.data;
};
patchOrganizationIdpSyncSettings = async (
data: TypesGen.OrganizationSyncSettings,
) => {
const response = await this.axios.patch<TypesGen.Response>(
"/api/v2/settings/idpsync/organization",
data,
);
return response.data;
};
/**
* @param organization Can be the organization's ID or name
*/
+24
View File
@@ -0,0 +1,24 @@
import { API } from "api/api";
import type { OrganizationSyncSettings } from "api/typesGenerated";
import type { QueryClient } from "react-query";
export const getOrganizationIdpSyncSettingsKey = () => [
"organizationIdpSyncSettings",
];
export const patchOrganizationSyncSettings = (queryClient: QueryClient) => {
return {
mutationFn: (request: OrganizationSyncSettings) =>
API.patchOrganizationIdpSyncSettings(request),
onSuccess: async () =>
await queryClient.invalidateQueries(getOrganizationIdpSyncSettingsKey()),
};
};
export const organizationIdpSyncSettings = (isIdpSyncEnabled: boolean) => {
return {
queryKey: getOrganizationIdpSyncSettingsKey(),
queryFn: () => API.getOrganizationIdpSyncSettings(),
enabled: isIdpSyncEnabled,
};
};
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Badge } from "./Badge";
const meta: Meta<typeof Badge> = {
title: "components/Badge",
component: Badge,
args: {
children: "Badge",
},
};
export default meta;
type Story = StoryObj<typeof Badge>;
export const Default: Story = {};
+32
View File
@@ -0,0 +1,32 @@
/**
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/badge}
*/
import { type VariantProps, cva } from "class-variance-authority";
import type { FC } from "react";
import { cn } from "utils/cn";
export const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-surface-secondary text-content-secondary shadow hover:bg-surface-tertiary",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export const Badge: FC<BadgeProps> = ({ className, variant, ...props }) => {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
};
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Trash } from "lucide-react";
import { Button } from "./Button";
const meta: Meta<typeof Button> = {
title: "components/Button",
component: Button,
args: {
children: (
<>
<Trash />
Button
</>
),
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = {};
export const Outline: Story = {
args: {
variant: "outline",
},
};
export const Subtle: Story = {
args: {
variant: "subtle",
},
};
export const Warning: Story = {
args: {
variant: "warning",
},
};
export const DefaultDisabled: Story = {
args: {
disabled: true,
},
};
export const OutlineDisabled: Story = {
args: {
variant: "outline",
disabled: true,
},
};
export const SubtleDisabled: Story = {
args: {
variant: "subtle",
disabled: true,
},
};
export const IconButtonDefault: Story = {
args: {
variant: "default",
children: <Trash />,
},
};
export const IconButtonOutline: Story = {
args: {
variant: "outline",
children: <Trash />,
},
};
export const IconButtonSubtle: Story = {
args: {
variant: "subtle",
children: <Trash />,
},
};
@@ -1,16 +1,19 @@
/**
* Copied from shadc/ui on 11/06/2024
* @see {@link https://ui.shadcn.com/docs/components/button}
*/
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { type FC, forwardRef } from "react";
import { cn } from "utils/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 font-semibold border-solid",
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link disabled:pointer-events-none disabled:text-content-disabled [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 font-semibold border-solid cursor-pointer",
{
variants: {
variant: {
default:
"bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary",
"bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary",
outline:
"border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary",
subtle:
@@ -19,7 +22,7 @@ const buttonVariants = cva(
"border border-border-error text-content-primary bg-surface-error hover:bg-transparent",
},
size: {
default: "h-10 px-3 py-2",
default: "h-9 px-3 py-2",
sm: "h-8 px-2 text-xs",
},
},
@@ -36,18 +39,16 @@ export interface ButtonProps
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
export const Button: FC<ButtonProps> = forwardRef<
HTMLButtonElement,
ButtonProps
>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
});
+145
View File
@@ -0,0 +1,145 @@
/**
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/command}
*/
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Dialog, DialogContent } from "components/Dialog/Dialog";
import { Search } from "lucide-react";
import { type FC, forwardRef } from "react";
import { cn } from "utils/cn";
export const Command = forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-surface-primary text-content-primary",
className,
)}
{...props}
/>
));
export const CommandDialog: FC<DialogProps> = ({ children, ...props }) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command
className={`
[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-content-disabled
[&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0
[&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5
[&_[cmdk-input]]:h-12
[&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3
[&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5
`}
>
{children}
</Command>
</DialogContent>
</Dialog>
);
};
export const CommandInput = forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
`flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none
placeholder:text-content-secondary
disabled:cursor-not-allowed disabled:opacity-50`,
className,
)}
{...props}
/>
</div>
));
export const CommandList = forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
export const CommandEmpty = forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
export const CommandGroup = forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
`overflow-hidden p-1 text-content-primary
[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs
[&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-content-secondary`,
className,
)}
{...props}
/>
));
export const CommandSeparator = forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
export const CommandItem = forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
`relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none
data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50
data-[selected=true]:bg-surface-secondary data-[selected=true]:text-content-primary
[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
className,
)}
{...props}
/>
));
export const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-content-disabled",
className,
)}
{...props}
/>
);
};
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import { Button } from "components/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./Dialog";
const meta: Meta<typeof Dialog> = {
title: "components/Dialog",
component: Dialog,
args: {
children: (
<>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Example Dialog Title</DialogTitle>
<DialogDescription>Dialog Description text</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button>Ok</Button>
</DialogFooter>
</DialogContent>
</>
),
},
};
export default meta;
type Story = StoryObj<typeof Dialog>;
export const OpenDialog: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Open Dialog" }));
},
};
+125
View File
@@ -0,0 +1,125 @@
/**
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/dialog}
*/
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import {
type ComponentPropsWithoutRef,
type ElementRef,
type FC,
type HTMLAttributes,
forwardRef,
} from "react";
import { cn } from "utils/cn";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
`fixed inset-0 z-50 bg-black/80
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0`,
className,
)}
{...props}
/>
));
export const DialogContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
`fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg
gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg
translate-x-[-50%] translate-y-[-50%]
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]
data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]`,
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className={`absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity
hover:opacity-100
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2
disabled:pointer-events-none
data-[state=open]:bg-surface-secondary data-[state=open]:text-content-disabled`}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
export const DialogHeader: FC<HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
export const DialogFooter: FC<HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
export const DialogTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
export const DialogDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-content-disabled", className)}
{...props}
/>
));
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "./Input";
const meta: Meta<typeof Input> = {
title: "components/Input",
component: Input,
args: {
placeholder: "Placeholder",
},
};
export default meta;
type Story = StoryObj<typeof Input>;
export const Default: Story = {};
export const Disabled: Story = {
args: {
disabled: true,
},
};
+28
View File
@@ -0,0 +1,28 @@
/**
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/input}
*/
import { forwardRef } from "react";
import { cn } from "utils/cn";
export const Input = forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
`flex h-9 w-full rounded-md border border-border border-solid bg-transparent px-3 py-1
text-base shadow-sm transition-colors
file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-content-primary
placeholder:text-content-secondary
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:cursor-not-allowed disabled:opacity-50 md:text-sm`,
className,
)}
ref={ref}
{...props}
/>
);
});
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Label } from "./Label";
const meta: Meta<typeof Label> = {
title: "components/Label",
component: Label,
args: {
children: "Label Text",
},
};
export default meta;
type Story = StoryObj<typeof Label>;
export const Default: Story = {};
+25
View File
@@ -0,0 +1,25 @@
/**
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/label}
*/
import * as LabelPrimitive from "@radix-ui/react-label";
import { type VariantProps, cva } from "class-variance-authority";
import { forwardRef } from "react";
import { cn } from "utils/cn";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
export const Label = forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
@@ -0,0 +1,100 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, waitFor, within } from "@storybook/test";
import { MockOrganization, MockOrganization2 } from "testHelpers/entities";
import { MultiSelectCombobox } from "./MultiSelectCombobox";
const organizations = [MockOrganization, MockOrganization2];
const meta: Meta<typeof MultiSelectCombobox> = {
title: "components/MultiSelectCombobox",
component: MultiSelectCombobox,
args: {
hidePlaceholderWhenSelected: true,
placeholder: "Select organization",
emptyIndicator: (
<p className="text-center text-md text-content-primary">
All organizations selected
</p>
),
defaultOptions: organizations.map((org) => ({
label: org.display_name,
value: org.id,
})),
},
};
export default meta;
type Story = StoryObj<typeof MultiSelectCombobox>;
export const Default: Story = {};
export const OpenCombobox: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByPlaceholderText("Select organization"));
await waitFor(() =>
expect(canvas.getByText("My Organization")).toBeInTheDocument(),
);
},
};
export const SelectComboboxItem: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByPlaceholderText("Select organization"));
await userEvent.click(
canvas.getByRole("option", { name: "My Organization" }),
);
},
};
export const SelectAllComboboxItems: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByPlaceholderText("Select organization"));
await userEvent.click(
canvas.getByRole("option", { name: "My Organization" }),
);
await userEvent.click(
canvas.getByRole("option", { name: "My Organization 2" }),
);
await waitFor(() =>
expect(
canvas.getByText("All organizations selected"),
).toBeInTheDocument(),
);
},
};
export const ClearFirstSelectedItem: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByPlaceholderText("Select organization"));
await userEvent.click(
canvas.getByRole("option", { name: "My Organization" }),
);
await userEvent.click(
canvas.getByRole("option", { name: "My Organization 2" }),
);
await userEvent.click(canvas.getAllByTestId("clear-option-button")[0]);
},
};
export const ClearAllComboboxItems: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByPlaceholderText("Select organization"));
await userEvent.click(
canvas.getByRole("option", { name: "My Organization" }),
);
await userEvent.click(canvas.getByTestId("clear-all-button"));
await waitFor(() =>
expect(
canvas.getByPlaceholderText("Select organization"),
).toBeInTheDocument(),
);
},
};
@@ -0,0 +1,651 @@
/**
* This component is based on multiple-selector
* @see {@link https://shadcnui-expansions.typeart.cc/docs/multiple-selector}
*/
import { Command as CommandPrimitive, useCommandState } from "cmdk";
import { Badge } from "components/Badge/Badge";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "components/Command/Command";
import { useDebouncedValue } from "hooks/debounce";
import { ChevronDown, X } from "lucide-react";
import {
type ComponentProps,
type ComponentPropsWithoutRef,
type KeyboardEvent,
type ReactNode,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "utils/cn";
export interface Option {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultiSelectComboboxProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: ReactNode;
/** Empty component. */
emptyIndicator?: ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @see {@link https://github.com/pacocoursey/cmdk/issues/171}
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
"value" | "placeholder" | "disabled"
>;
/** hide or show the button that clears all the selected options. */
hideClearAllButton?: boolean;
}
export interface MultiSelectComboboxRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
function transitionToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
"": options,
};
}
const groupOption: GroupOption = {};
for (const option of options) {
const key = (option[groupBy] as string) || "";
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
}
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = structuredClone(groupOption);
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter(
(val) => !picked.find((p) => p.value === val.value),
);
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
return Object.values(groupOption).some((value) =>
value.some((option) => targetOption.some((o) => o.value === option.value)),
);
}
/**
* The `CommandEmpty` of shadcn/ui will cause the cmdk-empty to not render correctly.
* Here a new CommandEmpty is created using the `Empty` implementation from `cmdk`.
*
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
**/
const CommandEmpty = forwardRef<
HTMLDivElement,
ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
ref={forwardedRef}
className={cn("py-6 text-center text-sm", className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
});
export const MultiSelectCombobox = forwardRef<
MultiSelectComboboxRef,
MultiSelectComboboxProps
>(
(
{
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultiSelectComboboxProps,
ref,
) => {
const inputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [onScrollbar, setOnScrollbar] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = useState<Option[]>(value || []);
const [options, setOptions] = useState<GroupOption>(
transitionToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = useState("");
const debouncedSearchTerm = useDebouncedValue(inputValue, delay || 500);
const [previousValue, setPreviousValue] = useState<Option[]>(value || []);
if (value && value !== previousValue) {
setPreviousValue(value);
setSelected(value);
}
useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current as HTMLInputElement,
focus: () => inputRef?.current?.focus(),
reset: () => setSelected([]),
}),
[selected],
);
const handleUnselect = useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === "Delete" || e.key === "Backspace") {
if (input.value === "" && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption.fixed) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
};
useEffect(() => {
if (!open) {
return;
}
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
if (open) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchend", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchend", handleClickOutside);
};
}, [open]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transitionToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transitionToGroupOption(res || [], groupBy));
};
const exec = () => {
if (!onSearchSync || !open) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
}, [
debouncedSearchTerm,
groupBy,
open,
triggerSearchOnFocus,
onSearchSync,
]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transitionToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
Create "{inputValue}"
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = () => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
};
const fixedOptions = selected.filter((s) => s.fixed);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn(
"h-auto overflow-visible bg-transparent",
commandProps?.className,
)}
shouldFilter={
commandProps?.shouldFilter !== undefined
? commandProps.shouldFilter
: !onSearch
} // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: onKeyDown is not needed here */}
<div
className={cn(
`*:min-h-9 rounded-md border border-solid border-border text-sm pr-3
focus-within:ring-2 focus-within:ring-content-link`,
{
"pl-3 py-1": selected.length !== 0,
"cursor-text": !disabled && selected.length !== 0,
},
className,
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
>
<div className="flex justify-between items-center">
<div className="relative flex flex-wrap gap-1">
{selected.map((option) => {
return (
<Badge
key={option.value}
className={cn(
"data-[disabled]:bg-content-disabled data-[disabled]:text-surface-tertiarydata-[disabled]:hover:bg-content-disabled",
"data-[fixed]:bg-content-disabled data-[fixed]:text-surface-tertiary data-[fixed]:hover:bg-surface-secondary",
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
type="button"
data-testid="clear-option-button"
className={cn(
`ml-1 pr-0 rounded-sm bg-transparent border-none outline-none
focus:ring-2 focus:ring-content-link focus:ml-2.5 focus:pl-0 cursor-pointer`,
(disabled || option.fixed) && "hidden",
)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
>
<X className="h-4 w-4 text-content-secondary hover:text-content-primary align-text-bottom" />
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
triggerSearchOnFocus && onSearch?.(debouncedSearchTerm);
inputProps?.onFocus?.(event);
}}
placeholder={
hidePlaceholderWhenSelected && selected.length !== 0
? ""
: placeholder
}
className={cn(
"flex-1 border-none outline-none bg-transparent placeholder:text-content-secondary",
{
"w-full": hidePlaceholderWhenSelected,
"px-3 py-2.5": selected.length === 0,
"ml-1": selected.length !== 0,
},
inputProps?.className,
)}
/>
</div>
<div className="flex items-center justify-between">
<button
type="button"
data-testid="clear-all-button"
onClick={() => {
setSelected(fixedOptions);
onChange?.(fixedOptions);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
setSelected(fixedOptions);
onChange?.(fixedOptions);
}
}}
className={cn(
"bg-transparent mt-1 border-none rounded-sm cursor-pointer text-content-secondary hover:text-content-primary outline-none focus:ring-2 focus:ring-content-link",
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
fixedOptions.length === selected.length) &&
"hidden",
)}
>
<X className="h-5 w-5" />
</button>
<ChevronDown className="h-6 w-6 cursor-pointer text-content-secondary hover:text-content-primary" />
</div>
</div>
</div>
<div className="relative">
{open && (
<CommandList
className={`absolute top-1 z-10 w-full rounded-md
border border-solid border-border
bg-surface-primary text-content-primary shadow-md outline-none
animate-in`}
onPointerLeave={() => {
setOnScrollbar(false);
}}
onPointerEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && (
<CommandItem value="-" className="hidden" />
)}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup
key={key}
heading={key}
className="h-full overflow-auto"
>
{/* biome-ignore lint/complexity/noUselessFragments: A parent element is
needed for multiple dropdown items */}
<>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
"cursor-pointer",
option.disable &&
"cursor-default text-content-disabled",
)}
>
{option.label}
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
);
},
);
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Switch } from "./Switch";
const meta: Meta<typeof Switch> = {
title: "components/Switch",
component: Switch,
};
export default meta;
type Story = StoryObj<typeof Switch>;
export const DefaultOn: Story = {
args: {
checked: true,
disabled: false,
},
};
export const DefaultOff: Story = {
args: {
checked: false,
disabled: false,
},
};
export const DisabledOn: Story = {
args: {
checked: true,
disabled: true,
},
};
export const DisabledOff: Story = {
args: {
checked: false,
disabled: true,
},
};
+35
View File
@@ -0,0 +1,35 @@
/**
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/switch}
*/
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { forwardRef } from "react";
import { cn } from "utils/cn";
export const Switch = forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
`peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full shadow-sm transition-colors
border-2 border-transparent
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary
disabled:cursor-not-allowed
data-[state=checked]:disabled:bg-surface-tertiary data-[state=unchecked]:disabled:bg-surface-tertiary
data-[state=checked]:hover:bg-surface-invert-secondary data-[state=unchecked]:hover:bg-surface-tertiary
data-[state=checked]:bg-surface-invert-primary data-[state=unchecked]:bg-surface-quaternary`,
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
`pointer-events-none block h-4 w-4 rounded-full bg-surface-primary shadow-lg ring-0 transition-transform
data-[state=checked]:translate-x-2.5 data-[state=unchecked]:-translate-x-1.5`,
)}
/>
</SwitchPrimitives.Root>
));
+7
View File
@@ -21,6 +21,7 @@ export const checks = {
createGroup: "createGroup",
viewAllLicenses: "viewAllLicenses",
viewNotificationTemplate: "viewNotificationTemplate",
viewOrganizationIDPSyncSettings: "viewOrganizationIDPSyncSettings",
} as const satisfies Record<string, string>;
// Type expression seems a little redundant (`keyof typeof checks` has the same
@@ -152,6 +153,12 @@ export const permissionsToCheck = {
},
action: "read",
},
[checks.viewOrganizationIDPSyncSettings]: {
object: {
resource_type: "idpsync_settings",
},
action: "read",
},
} as const satisfies Record<PermissionValue, AuthorizationCheck>;
export type Permissions = Record<PermissionValue, boolean>;
+43 -27
View File
@@ -4,50 +4,66 @@
@layer base {
:root {
--content-primary: 240, 3%, 15%;
--content-secondary: 240, 4%, 32%;
--content-link: 217, 84%, 54%;
--content-primary: 240, 10%, 4%;
--content-secondary: 240, 5%, 34%;
--content-link: 221, 83%, 53%;
--content-invert: 0, 0%, 98%;
--content-disabled: 240, 4%, 66%;
--content-success: 144, 83%, 32%;
--content-danger: 0, 82%, 63%;
--content-disabled: 240, 5%, 65%;
--content-success: 142, 72%, 29%;
--content-danger: 0, 84%, 60%;
--content-warning: 27, 96%, 61%;
--surface-primary: 0, 0%, 98%;
--surface-secondary: 240, 4%, 96%;
--surface-tertiary: 240, 4%, 89%;
--surface-invert-primary: 240, 3%, 15%;
--surface-invert-secondary: 240, 4%, 25%;
--surface-secondary: 240, 5%, 96%;
--surface-tertiary: 240, 6%, 90%;
--surface-quaternary: 240, 5%, 84%;
--surface-invert-primary: 240, 4%, 16%;
--surface-invert-secondary: 240, 5%, 26%;
--surface-error: 0, 100%, 14%;
--border-default: 240, 4%, 89%;
--border-error: 0, 82%, 63%;
--border-default: 240, 6%, 90%;
--border-success: 142, 76%, 36%;
--border-error: 0, 84%, 60%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--background: 0, 0%, 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
}
.dark {
--content-primary: 0, 0%, 98%;
--content-secondary: 240, 4%, 66%;
--content-link: 217, 92%, 67%;
--content-invert: 240, 5%, 4%;
--content-disabled: 240, 4%, 25%;
--content-success: 144, 83%, 35%;
--content-danger: 0, 82%, 71%;
--surface-primary: 240, 5%, 4%;
--surface-secondary: 240, 5%, 9%;
--surface-tertiary: 240, 3%, 15%;
--surface-invert-primary: 240, 4%, 89%;
--surface-invert-secondary: 240, 4%, 66%;
--content-secondary: 240, 5%, 65%;
--content-link: 213, 94%, 68%;
--content-invert: 240, 10%, 4%;
--content-disabled: 240, 5%, 26%;
--content-success: 142, 76%, 36%;
--content-danger: 0, 91%, 71%;
--content-warning: 27, 96%, 61%;
--surface-primary: 240, 10%, 4%;
--surface-secondary: 240, 6%, 10%;
--surface-tertiary: 240, 4%, 16%;
--surface-quaternary: 240, 5%, 26%;
--surface-invert-primary: 240, 6%, 90%;
--surface-invert-secondary: 240, 5%, 65%;
--surface-error: 0, 100%, 14%;
--border-default: 240, 3%, 15%;
--border-error: 0, 82%, 71%;
--border-default: 240, 4%, 16%;
--border-success: 142, 76%, 36%;
--border-error: 0, 91%, 71%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--background: 240, 5%, 4%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
}
@@ -155,6 +155,11 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
</Stack>
</SidebarNavSubItem>
)}
{permissions.viewOrganizationIDPSyncSettings && (
<SidebarNavSubItem href="idp-org-sync">
IdP Organization Sync
</SidebarNavSubItem>
)}
{!isPremium && (
<SidebarNavSubItem href="premium">Premium</SidebarNavSubItem>
)}
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, waitFor, within } from "@storybook/test";
import { MockOrganizationSyncSettings } from "testHelpers/entities";
import { ExportPolicyButton } from "./ExportPolicyButton";
const meta: Meta<typeof ExportPolicyButton> = {
title: "pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton",
component: ExportPolicyButton,
args: {
syncSettings: MockOrganizationSyncSettings,
},
};
export default meta;
type Story = StoryObj<typeof ExportPolicyButton>;
export const Default: Story = {};
export const ClickExportPolicy: Story = {
args: {
syncSettings: MockOrganizationSyncSettings,
download: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Export Policy" }),
);
await waitFor(() =>
expect(args.download).toHaveBeenCalledWith(
expect.anything(),
"organizations_policy.json",
),
);
const blob: Blob = (args.download as jest.Mock).mock.lastCall[0];
await expect(blob.type).toEqual("application/json");
await expect(await blob.text()).toEqual(
JSON.stringify(MockOrganizationSyncSettings, null, 2),
);
},
};
@@ -0,0 +1,47 @@
import type { OrganizationSyncSettings } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { displayError } from "components/GlobalSnackbar/utils";
import { saveAs } from "file-saver";
import { Download } from "lucide-react";
import { type FC, useState } from "react";
interface ExportPolicyButtonProps {
syncSettings: OrganizationSyncSettings | undefined;
download?: (file: Blob, filename: string) => void;
}
export const ExportPolicyButton: FC<ExportPolicyButtonProps> = ({
syncSettings,
download = saveAs,
}) => {
const [isDownloading, setIsDownloading] = useState(false);
const canCreatePolicyJson =
syncSettings?.field && Object.keys(syncSettings?.mapping).length > 0;
return (
<Button
variant={"outline"}
disabled={!canCreatePolicyJson || isDownloading}
onClick={async () => {
if (canCreatePolicyJson) {
try {
setIsDownloading(true);
const file = new Blob([JSON.stringify(syncSettings, null, 2)], {
type: "application/json",
});
download(file, "organizations_policy.json");
} catch (e) {
console.error(e);
displayError("Failed to export organizations policy json");
} finally {
setIsDownloading(false);
}
}
}}
>
<Download size={14} />
Export Policy
</Button>
);
};
@@ -0,0 +1,110 @@
import { getErrorMessage } from "api/errors";
import {
organizationIdpSyncSettings,
patchOrganizationSyncSettings,
} from "api/queries/idpsync";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { displayError } from "components/GlobalSnackbar/utils";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import { Paywall } from "components/Paywall/Paywall";
import { SquareArrowOutUpRight } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { type FC, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { docs } from "utils/docs";
import { pageTitle } from "utils/page";
import { ExportPolicyButton } from "./ExportPolicyButton";
import IdpOrgSyncPageView from "./IdpOrgSyncPageView";
export const IdpOrgSyncPage: FC = () => {
const queryClient = useQueryClient();
// IdP sync does not have its own entitlement and is based on templace_rbac
const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility();
const { organizations } = useDashboard();
const {
data: orgSyncSettingsData,
isLoading,
error,
} = useQuery(organizationIdpSyncSettings(isIdpSyncEnabled));
const patchOrganizationSyncSettingsMutation = useMutation(
patchOrganizationSyncSettings(queryClient),
);
useEffect(() => {
if (patchOrganizationSyncSettingsMutation.error) {
displayError(
getErrorMessage(
patchOrganizationSyncSettingsMutation.error,
"Error updating organization idp sync settings.",
),
);
}
}, [patchOrganizationSyncSettingsMutation.error]);
if (isLoading) {
return <Loader />;
}
return (
<>
<Helmet>
<title>{pageTitle("Organization IdP Sync")}</title>
</Helmet>
<div className="flex flex-col gap-12">
<header className="flex flex-row items-baseline justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-3xl m-0">Organization IdP Sync</h1>
<p className="flex flex-row gap-1 text-sm text-content-secondary font-medium m-0">
Automatically assign users to an organization based on their IdP
claims.
<a
href={docs("/admin/users/idp-sync")}
className="flex flex-row text-content-link items-center gap-1 no-underline hover:underline visited:text-content-link"
>
View docs
<SquareArrowOutUpRight size={14} />
</a>
</p>
</div>
<ExportPolicyButton syncSettings={orgSyncSettingsData} />
</header>
<ChooseOne>
<Cond condition={!isIdpSyncEnabled}>
<Paywall
message="IdP Organization Sync"
description="Configure organization mappings to synchronize claims in your auth provider to organizations within Coder. You need an Premium license to use this feature."
documentationLink={docs("/admin/users/idp-sync")}
/>
</Cond>
<Cond>
<IdpOrgSyncPageView
organizationSyncSettings={orgSyncSettingsData}
organizations={organizations}
onSubmit={async (data) => {
try {
await patchOrganizationSyncSettingsMutation.mutateAsync(data);
displaySuccess("Organization sync settings updated.");
} catch (error) {
displayError(
getErrorMessage(
error,
"Failed to update organization IdP sync settings",
),
);
}
}}
error={error || patchOrganizationSyncSettingsMutation.error}
/>
</Cond>
</ChooseOne>
</div>
</>
);
};
export default IdpOrgSyncPage;
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockOrganization,
MockOrganization2,
MockOrganizationSyncSettings,
MockOrganizationSyncSettings2,
} from "testHelpers/entities";
import { IdpOrgSyncPageView } from "./IdpOrgSyncPageView";
const meta: Meta<typeof IdpOrgSyncPageView> = {
title: "pages/IdpOrgSyncPageView",
component: IdpOrgSyncPageView,
};
export default meta;
type Story = StoryObj<typeof IdpOrgSyncPageView>;
export const Empty: Story = {
args: {
organizationSyncSettings: {
field: "",
mapping: {},
organization_assign_default: true,
},
organizations: [MockOrganization, MockOrganization2],
error: undefined,
},
};
export const Default: Story = {
args: {
organizationSyncSettings: MockOrganizationSyncSettings2,
organizations: [MockOrganization, MockOrganization2],
error: undefined,
},
};
export const HasError: Story = {
args: {
...Default.args,
error: "This is a test error",
},
};
export const MissingGroups: Story = {
args: {
...Default.args,
organizationSyncSettings: MockOrganizationSyncSettings,
},
};
@@ -0,0 +1,352 @@
import Skeleton from "@mui/material/Skeleton";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import type {
Organization,
OrganizationSyncSettings,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipText,
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import {
MultiSelectCombobox,
type Option,
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { Switch } from "components/Switch/Switch";
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import { useFormik } from "formik";
import { Plus, SquareArrowOutUpRight, Trash } from "lucide-react";
import { type FC, useState } from "react";
import { docs } from "utils/docs";
import * as Yup from "yup";
import { OrganizationPills } from "./OrganizationPills";
interface IdpSyncPageViewProps {
organizationSyncSettings: OrganizationSyncSettings | undefined;
organizations: readonly Organization[];
onSubmit: (data: OrganizationSyncSettings) => void;
error?: unknown;
}
const validationSchema = Yup.object({
field: Yup.string().trim(),
organization_assign_default: Yup.boolean(),
mapping: Yup.object().shape({
[`${String}`]: Yup.array().of(Yup.string()),
}),
});
export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
organizationSyncSettings,
organizations,
onSubmit,
error,
}) => {
const form = useFormik<OrganizationSyncSettings>({
initialValues: {
field: organizationSyncSettings?.field ?? "",
organization_assign_default:
organizationSyncSettings?.organization_assign_default ?? true,
mapping: organizationSyncSettings?.mapping ?? {},
},
validationSchema: validationSchema,
onSubmit,
enableReinitialize: Boolean(organizationSyncSettings),
});
const [coderOrgs, setCoderOrgs] = useState<Option[]>([]);
const [idpOrgName, setIdpOrgName] = useState("");
const organizationMappingCount = form.values.mapping
? Object.entries(form.values.mapping).length
: 0;
const getOrgNames = (orgIds: readonly string[]) => {
return orgIds.map(
(orgId) =>
organizations.find((org) => org.id === orgId)?.display_name || orgId,
);
};
const handleDelete = async (idpOrg: string) => {
const newMapping = Object.fromEntries(
Object.entries(form.values.mapping || {}).filter(
([key]) => key !== idpOrg,
),
);
const newSyncSettings = {
...form.values,
mapping: newMapping,
};
void form.setFieldValue("mapping", newSyncSettings.mapping);
form.handleSubmit();
};
const SYNC_FIELD_ID = "sync-field";
const ORGANIZATION_ASSIGN_DEFAULT_ID = "organization-assign-default";
const IDP_ORGANIZATION_NAME_ID = "idp-organization-name";
return (
<div className="flex flex-col gap-2">
{Boolean(error) && <ErrorAlert error={error} />}
<form onSubmit={form.handleSubmit}>
<fieldset disabled={form.isSubmitting} className="border-none">
<div className="flex flex-row">
<div className="grid items-center gap-1">
<Label className="text-sm" htmlFor={SYNC_FIELD_ID}>
Organization sync field
</Label>
<div className="flex flex-row items-center gap-5">
<div className="flex flex-row gap-2 w-72">
<Input
id={SYNC_FIELD_ID}
value={form.values.field}
onChange={async (event) => {
void form.setFieldValue("field", event.target.value);
}}
/>
<Button
className="w-20"
type="submit"
disabled={form.isSubmitting || !form.dirty}
onClick={(event) => {
event.preventDefault();
form.handleSubmit();
}}
>
Save
</Button>
</div>
<div className="flex flex-row items-center gap-3">
<Switch
id={ORGANIZATION_ASSIGN_DEFAULT_ID}
checked={form.values.organization_assign_default}
onCheckedChange={async (checked) => {
void form.setFieldValue(
"organization_assign_default",
checked,
);
form.handleSubmit();
}}
/>
<span className="flex flex-row items-center gap-1">
<Label htmlFor={ORGANIZATION_ASSIGN_DEFAULT_ID}>
Assign Default Organization
</Label>
<AssignDefaultOrgHelpTooltip />
</span>
</div>
</div>
<p className="text-content-secondary text-2xs m-0">
If empty, organization sync is deactivated
</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-row pt-8 gap-2 justify-between items-start">
<div className="grid items-center gap-1">
<Label className="text-sm" htmlFor={IDP_ORGANIZATION_NAME_ID}>
IdP organization name
</Label>
<Input
id={IDP_ORGANIZATION_NAME_ID}
value={idpOrgName}
className="min-w-72 w-72"
onChange={(event) => {
setIdpOrgName(event.target.value);
}}
/>
</div>
<div className="grid items-center gap-1 flex-1">
<Label className="text-sm" htmlFor=":r1d:">
Coder organization
</Label>
<MultiSelectCombobox
className="min-w-60 max-w-3xl"
value={coderOrgs}
onChange={setCoderOrgs}
defaultOptions={organizations.map((org) => ({
label: org.display_name,
value: org.id,
}))}
hidePlaceholderWhenSelected
placeholder="Select organization"
emptyIndicator={
<p className="text-center text-md text-content-primary">
All organizations selected
</p>
}
/>
</div>
<div className="grid items-center gap-1">
&nbsp;
<Button
className="mb-px"
type="submit"
disabled={!idpOrgName || coderOrgs.length === 0}
onClick={async () => {
const newSyncSettings = {
...form.values,
mapping: {
...form.values.mapping,
[idpOrgName]: coderOrgs.map((org) => org.value),
},
};
void form.setFieldValue("mapping", newSyncSettings.mapping);
form.handleSubmit();
setIdpOrgName("");
setCoderOrgs([]);
}}
>
<Plus size={14} />
Add IdP organization
</Button>
</div>
</div>
<IdpMappingTable isEmpty={organizationMappingCount === 0}>
{form.values.mapping &&
Object.entries(form.values.mapping)
.sort()
.map(([idpOrg, organizations]) => (
<OrganizationRow
key={idpOrg}
idpOrg={idpOrg}
coderOrgs={getOrgNames(organizations)}
onDelete={handleDelete}
/>
))}
</IdpMappingTable>
</div>
</fieldset>
</form>
</div>
);
};
interface IdpMappingTableProps {
isEmpty: boolean;
children: React.ReactNode;
}
const IdpMappingTable: FC<IdpMappingTableProps> = ({ isEmpty, children }) => {
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="45%">IdP organization</TableCell>
<TableCell width="55%">Coder organization</TableCell>
<TableCell width="10%" />
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={"No organization mappings"}
isCompact
cta={
<Button variant="outline" asChild>
<a
href={docs("/admin/users/idp-sync")}
className="no-underline"
>
<SquareArrowOutUpRight size={14} />
How to set up IdP organization sync
</a>
</Button>
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>{children}</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
);
};
interface OrganizationRowProps {
idpOrg: string;
coderOrgs: readonly string[];
onDelete: (idpOrg: string) => void;
}
const OrganizationRow: FC<OrganizationRowProps> = ({
idpOrg,
coderOrgs,
onDelete,
}) => {
return (
<TableRow data-testid={`idp-org-${idpOrg}`}>
<TableCell>{idpOrg}</TableCell>
<TableCell>
<OrganizationPills organizations={coderOrgs} />
</TableCell>
<TableCell>
<Button
variant="outline"
className="w-8 h-8 px-1.5 py-1.5 text-content-secondary"
aria-label="delete"
onClick={() => onDelete(idpOrg)}
>
<Trash />
<span className="sr-only">Delete IdP mapping</span>
</Button>
</TableCell>
</TableRow>
);
};
const TableLoader = () => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="10%" />
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
);
};
export const AssignDefaultOrgHelpTooltip: FC = () => {
return (
<HelpTooltip>
<HelpTooltipTrigger />
<HelpTooltipContent>
<HelpTooltipText>
Disabling will remove all users from the default organization.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
);
};
export default IdpOrgSyncPageView;
@@ -0,0 +1,103 @@
import { useTheme } from "@emotion/react";
import { Pill } from "components/Pill/Pill";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import type { FC } from "react";
import { cn } from "utils/cn";
import { isUUID } from "utils/uuid";
interface OrganizationPillsProps {
organizations: readonly string[];
}
export const OrganizationPills: FC<OrganizationPillsProps> = ({
organizations,
}) => {
const orgs = organizations.map((org) => ({
name: org,
isUUID: isUUID(org),
}));
return (
<div className="flex flex-row gap-2">
{orgs.length > 0 ? (
<Pill
className={cn("border-none w-fit", {
"bg-surface-error": orgs[0].isUUID,
"bg-surface-secondary": !orgs[0].isUUID,
})}
>
{orgs[0].name}
</Pill>
) : (
<p>None</p>
)}
{orgs.length > 1 && <OverflowPillList organizations={orgs.slice(1)} />}
</div>
);
};
interface OverflowPillProps {
organizations: { name: string; isUUID: boolean }[];
}
const OverflowPillList: FC<OverflowPillProps> = ({ organizations }) => {
const theme = useTheme();
return (
<Popover mode="hover">
<PopoverTrigger>
<Pill
className="min-h-4 min-w-6 bg-surface-secondary border-none px-3 py-1"
data-testid="overflow-pill"
>
+{organizations.length}
</Pill>
</PopoverTrigger>
<PopoverContent
disableRestoreFocus
disableScrollLock
css={{
".MuiPaper-root": {
display: "flex",
flexFlow: "column wrap",
columnGap: 8,
rowGap: 12,
padding: "12px 16px",
alignContent: "space-around",
minWidth: "auto",
backgroundColor: theme.palette.background.default,
},
}}
anchorOrigin={{
vertical: -4,
horizontal: "center",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "center",
}}
>
<ul className="list-none my-0 pl-0">
{organizations.map((organization) => (
<li key={organization.name} className="mb-2 last:mb-0">
<Pill
className={cn("border-none w-fit", {
"bg-surface-error": organization.isUUID,
"bg-surface-secondary": !organization.isUUID,
})}
>
{organization.name}
</Pill>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
};
@@ -1,4 +1,4 @@
import { Button } from "components/ui/button";
import { Button } from "components/Button/Button";
import { Activity, Coins, Expand, SquareArrowOutUpRight } from "lucide-react";
import type { FC } from "react";
import { docs } from "utils/docs";
@@ -8,7 +8,7 @@ import {
import { ExportPolicyButton } from "./ExportPolicyButton";
const meta: Meta<typeof ExportPolicyButton> = {
title: "modules/resources/ExportPolicyButton",
title: "pages/IdpSyncPage/ExportPolicyButton",
component: ExportPolicyButton,
args: {
syncSettings: MockGroupSyncSettings,
@@ -7,7 +7,7 @@ import type {
} from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { saveAs } from "file-saver";
import { type FC, useMemo, useState } from "react";
import { type FC, useState } from "react";
interface DownloadPolicyButtonProps {
syncSettings: RoleSyncSettings | GroupSyncSettings | undefined;
@@ -24,21 +24,18 @@ export const ExportPolicyButton: FC<DownloadPolicyButtonProps> = ({
}) => {
const [isDownloading, setIsDownloading] = useState(false);
const policyJSON = useMemo(() => {
return syncSettings?.field && syncSettings.mapping
? JSON.stringify(syncSettings, null, 2)
: null;
}, [syncSettings]);
const canCreatePolicyJson =
syncSettings?.field && Object.keys(syncSettings?.mapping).length > 0;
return (
<Button
startIcon={<DownloadOutlined />}
disabled={!policyJSON || isDownloading}
disabled={!canCreatePolicyJson || isDownloading}
onClick={async () => {
if (policyJSON) {
if (canCreatePolicyJson) {
try {
setIsDownloading(true);
const file = new Blob([policyJSON], {
const file = new Blob([JSON.stringify(syncSettings, null, 2)], {
type: "application/json",
});
download(file, `${organization.name}_${type}-policy.json`);
@@ -7,20 +7,17 @@ import {
PopoverTrigger,
} from "components/Popover/Popover";
import type { FC } from "react";
import { isUUID } from "utils/uuid";
interface PillListProps {
roles: readonly string[];
}
// used to check if the role is a UUID
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export const IdpPillList: FC<PillListProps> = ({ roles }) => {
return (
<Stack direction="row" spacing={1}>
{roles.length > 0 ? (
<Pill css={UUID.test(roles[0]) ? styles.errorPill : styles.pill}>
<Pill css={isUUID(roles[0]) ? styles.errorPill : styles.pill}>
{roles[0]}
</Pill>
) : (
@@ -78,10 +75,7 @@ const OverflowPill: FC<OverflowPillProps> = ({ roles }) => {
}}
>
{roles.map((role) => (
<Pill
key={role}
css={UUID.test(role) ? styles.errorPill : styles.pill}
>
<Pill key={role} css={isUUID(role) ? styles.errorPill : styles.pill}>
{role}
</Pill>
))}
@@ -12,7 +12,7 @@ import {
import { IdpSyncPageView } from "./IdpSyncPageView";
const meta: Meta<typeof IdpSyncPageView> = {
title: "pages/OrganizationIdpSyncPage",
title: "pages/IdpSyncPage",
component: IdpSyncPageView,
};
+4
View File
@@ -301,6 +301,9 @@ const RequestOTPPage = lazy(
const ChangePasswordPage = lazy(
() => import("./pages/ResetPasswordPage/ChangePasswordPage"),
);
const IdpOrgSyncPage = lazy(
() => import("./pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage"),
);
const RoutesWithSuspense = () => {
return (
@@ -453,6 +456,7 @@ export const router = createBrowserRouter(
path="notifications"
element={<DeploymentNotificationsPage />}
/>
<Route path="idp-org-sync" element={<IdpOrgSyncPage />} />
<Route path="premium" element={<PremiumPage />} />
</Route>
+24
View File
@@ -2674,6 +2674,28 @@ export const MockRoleSyncSettings: TypesGen.RoleSyncSettings = {
},
};
export const MockOrganizationSyncSettings: TypesGen.OrganizationSyncSettings = {
field: "organization-test",
mapping: {
"idp-org-1": [
"fbd2116a-8961-4954-87ae-e4575bd29ce0",
"13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2",
],
"idp-org-2": ["fbd2116a-8961-4954-87ae-e4575bd29ce0"],
},
organization_assign_default: true,
};
export const MockOrganizationSyncSettings2: TypesGen.OrganizationSyncSettings =
{
field: "organization-test",
mapping: {
"idp-org-1": ["my-organization-id", "my-organization-2-id"],
"idp-org-2": ["my-organization-id"],
},
organization_assign_default: true,
};
export const MockGroup: TypesGen.Group = {
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
name: "Front-End",
@@ -2774,6 +2796,7 @@ export const MockPermissions: Permissions = {
createGroup: true,
viewAllLicenses: true,
viewNotificationTemplate: true,
viewOrganizationIDPSyncSettings: true,
};
export const MockNoPermissions: Permissions = {
@@ -2797,6 +2820,7 @@ export const MockNoPermissions: Permissions = {
createGroup: false,
viewAllLicenses: false,
viewNotificationTemplate: false,
viewOrganizationIDPSyncSettings: false,
};
export const MockDeploymentConfig: DeploymentConfig = {
+5
View File
@@ -0,0 +1,5 @@
export const isUUID = (text: string) => {
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return UUID.test(text);
};
+10 -3
View File
@@ -9,7 +9,7 @@ module.exports = {
theme: {
extend: {
fontSize: {
"2xs": ["0.626rem", "0.875rem"],
"2xs": ["0.625rem", "0.875rem"],
sm: ["0.875rem", "1.5rem"],
"3xl": ["2rem", "2.5rem"],
},
@@ -32,6 +32,7 @@ module.exports = {
primary: "hsl(var(--surface-primary))",
secondary: "hsl(var(--surface-secondary))",
tertiary: "hsl(var(--surface-tertiary))",
quaternary: "hsl(var(--surface-quaternary))",
invert: {
primary: "hsl(var(--surface-invert-primary))",
secondary: "hsl(var(--surface-invert-secondary))",
@@ -42,8 +43,14 @@ module.exports = {
DEFAULT: "hsl(var(--border-default))",
error: "hsl(var(--border-error))",
},
background: {
DEFAULT: "hsl(var(--background))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
},
+1 -1
View File
@@ -16,7 +16,7 @@
"skipLibCheck": true,
"strict": true,
"target": "es2020",
"baseUrl": "src/"
"baseUrl": "src/",
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules/", "_jest"],