mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -16,5 +16,6 @@
|
||||
"ui": "/components/ui",
|
||||
"lib": "/lib",
|
||||
"hooks": "/hooks"
|
||||
}
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
||||
+11
-1
@@ -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"
|
||||
|
||||
Generated
+942
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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" }));
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 = {};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
));
|
||||
@@ -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
@@ -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">
|
||||
|
||||
<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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
@@ -16,7 +16,7 @@
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "es2020",
|
||||
"baseUrl": "src/"
|
||||
"baseUrl": "src/",
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules/", "_jest"],
|
||||
|
||||
Reference in New Issue
Block a user